ConusAI vendor v0.1.0-beta.114
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-beta.114
2026-11-14 09:56:45 +00:00
commit 1ce8d23c35
867 changed files with 122104 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The client entry: start observability before the app hydrates (what Next's `instrumentation-client.ts`
// did; src/observe/browser.ts holds all of it), then hydrate the document the server rendered.
//
// Install React event replay immediately, and prepare the current view before its first render.
// The server-rendered page remains in place while the router and its matched view load.
import "./zod-browser"
import { instrumentationClient } from "@conusai-sdk/observe-start/client"
import { Await, RouterProvider } from "@tanstack/react-router"
import { hydrateStart } from "@tanstack/react-start/client"
import { StrictMode, startTransition } from "react"
import { hydrateRoot } from "react-dom/client"
import { startBrowserObserve } from "./observe/browser"
import { prepareInitialModuleViews } from "./ui/page-views"
export const observe = startBrowserObserve(document, instrumentationClient)
const hydration = hydrateStart().then(async (router) => {
await prepareInitialModuleViews(router.state.matches)
return router
})
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<Await promise={hydration}>{(router) => <RouterProvider router={router} />}</Await>
</StrictMode>,
)
})
+98
View File
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The browser half of observability (spec §9.5): `instrumentation-client.ts` runs this before the
// app hydrates, so an error thrown during hydration is already captured.
//
// There is no `NEXT_PUBLIC_*` value to bake in (spec §6.4): the server writes the two facts the
// browser needs -- that observability is on, and the release and environment to report -- into the
// document as a `<meta>` element (`observeMeta`), and this module reads them back. A deployment
// without a DSN writes nothing, and the browser starts nothing: no request to the platform's
// ingest, no handler on `window`, and no console noise.
//
// On the platform the SDK sends to this app's own origin (`/api/_conusai/*`, which the edge answers),
// so the content security policy needs no extra `connect-src`.
import type { Observe, ObserveOptions } from "@conusai-sdk/observe"
import { type ConsentChoice, type PrivacyNavigator, privacyRestricted, readConsentChoice } from "./consent"
export const OBSERVE_META_NAME = "app-observe"
/** What the server tells the browser. */
export interface BrowserObserveSettings {
readonly release: string
readonly environment: string
}
/** The attributes of the `<meta>` element the document carries when observability is on. */
export function observeMeta(settings: BrowserObserveSettings): Record<string, string> {
return {
name: OBSERVE_META_NAME,
content: "on",
"data-release": settings.release,
"data-environment": settings.environment,
}
}
/** The settings in a document, or nothing when the server did not write them. */
export function readBrowserSettings(document: Pick<Document, "querySelector">): BrowserObserveSettings | undefined {
const meta = document.querySelector(`meta[name="${OBSERVE_META_NAME}"]`)
if (meta?.getAttribute("content") !== "on") return undefined
return {
release: meta.getAttribute("data-release") ?? "dev",
environment: meta.getAttribute("data-environment") ?? "production",
}
}
/** Analytics and traces wait for opt-in. Replay is a separate purpose and loads only after its own yes. */
export function browserObserveOptions(
settings: BrowserObserveSettings,
choice?: ConsentChoice,
restricted = false,
): ObserveOptions {
return {
release: settings.release,
environment: settings.environment,
consent: "opt-in",
respectGPC: true,
modules: { traces: true, analytics: true, ...(choice?.replay && !restricted ? { replay: true } : {}) },
}
}
/** Restores a recorded choice without storing anything new. GPC always wins over an older grant. */
export function startBrowserObserve(
document: Pick<Document, "querySelector" | "cookie">,
start: (options: ObserveOptions) => Observe,
navigator?: PrivacyNavigator,
): Observe | undefined {
const settings = readBrowserSettings(document)
const restricted = privacyRestricted(navigator ?? globalThis.navigator)
const choice = readConsentChoice(document.cookie)
const started = settings ? start(browserObserveOptions(settings, choice, restricted)) : undefined
if (started) {
holder()[INSTANCE] = started
if (restricted || choice?.analytics === false || (!choice && edgeGranted(document.cookie))) started.consent.revoke()
else if (choice?.analytics) started.consent.grant()
}
return started
}
/**
* The edge's own record of a grant (`_conusai_consent`), which outlives a choice of this app that is
* missing, malformed or under an older notice: withdrawn then, or the edge goes on setting its cookies
* while the banner asks again. Nothing is written for a visitor the edge holds no grant for.
*/
const edgeGranted = (cookie: string | undefined): boolean =>
cookie?.split(";").some((part) => part.trim() === "_conusai_consent=granted") ?? false
// The instance is kept on `globalThis`: `instrumentation-client.ts` is its own entry in the bundle
// graph, and an error boundary further down must reach the very instance it started.
const INSTANCE = Symbol.for("conusai.app.observe")
const holder = (): Record<symbol, Observe | undefined> => globalThis as unknown as Record<symbol, Observe | undefined>
/**
* Reports an error an error boundary caught. React swallows what a boundary catches, so the SDK's
* `window` handlers never see it; this is how it reaches observability. A no-op when the deployment
* has none.
*/
export function reportClientError(error: unknown): void {
holder()[INSTANCE]?.captureException(error)
}
+73
View File
@@ -0,0 +1,73 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import type { Observe } from "@conusai-sdk/observe"
export const CONSENT_COOKIE = "app_consent"
export const CONSENT_NOTICE_VERSION = "2026-10"
export const CONSENT_SECONDS = 60 * 60 * 24 * 182
export interface ConsentChoice {
readonly analytics: boolean
readonly replay: boolean
readonly at: string
readonly notice: string
}
export interface PrivacyNavigator {
readonly globalPrivacyControl?: unknown
readonly doNotTrack?: unknown
}
export function privacyRestricted(navigator: PrivacyNavigator | undefined): boolean {
try {
return navigator?.globalPrivacyControl === true || navigator?.doNotTrack === "1"
} catch {
return true
}
}
export function readConsentChoice(cookie: string | undefined, now = Date.now()): ConsentChoice | undefined {
const raw = cookie
?.split(";")
.map((part) => part.trim())
.find((part) => part.startsWith(`${CONSENT_COOKIE}=`))
?.slice(CONSENT_COOKIE.length + 1)
if (!raw) return undefined
try {
const choice = JSON.parse(decodeURIComponent(raw)) as Partial<ConsentChoice>
const at = typeof choice.at === "string" ? Date.parse(choice.at) : Number.NaN
if (
typeof choice.analytics !== "boolean" ||
typeof choice.replay !== "boolean" ||
choice.notice !== CONSENT_NOTICE_VERSION ||
!Number.isFinite(at) ||
at > now ||
now - at >= CONSENT_SECONDS * 1000
) {
return undefined
}
return { analytics: choice.analytics, replay: choice.replay, at: choice.at as string, notice: choice.notice }
} catch {
return undefined
}
}
export function consentCookie(choice: ConsentChoice): string {
return `${CONSENT_COOKIE}=${encodeURIComponent(JSON.stringify(choice))}; Path=/; Max-Age=${CONSENT_SECONDS}; SameSite=Lax; Secure`
}
export function browserObserve(): Observe | undefined {
return (globalThis as unknown as Record<symbol, Observe | undefined>)[Symbol.for("conusai.app.observe")]
}
export function recordConsentChoice(
choice: Pick<ConsentChoice, "analytics" | "replay">,
now = new Date(),
): ConsentChoice {
const record = { ...choice, at: now.toISOString(), notice: CONSENT_NOTICE_VERSION }
const observe = browserObserve()
if (choice.analytics && !privacyRestricted(globalThis.navigator)) observe?.consent.grant()
else observe?.consent.revoke()
if (!choice.replay) observe?.replay.stop()
return record
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The router: the generated route tree (src/routeTree.gen.ts, written by the Start plugin and not
// committed), and the request's nonce for the scripts the renderer injects (src/server.ts makes it).
import { navigationTransitions } from "@conusai-kit/ui/frame"
import { createRouter } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeader } from "@tanstack/react-start/server"
import { routeTree } from "./routeTree.gen"
import { RoutePending } from "./ui/route-pending"
const requestNonce = createIsomorphicFn()
.server(() => getRequestHeader("x-nonce"))
.client(() => undefined)
export function getRouter() {
const nonce = requestNonce()
return createRouter({
routeTree,
defaultPendingComponent: RoutePending,
defaultPendingMs: 180,
defaultPendingMinMs: 220,
defaultViewTransition: navigationTransitions(),
...(nonce ? { ssr: { nonce } } : {}),
// Every loader runs on the server for every navigation (F10): nothing is preloaded and nothing cached in the browser,
// and the browser restores the scroll position of a page it loaded, so the router keeps no session storage.
defaultPreload: false,
defaultStaleTime: 0,
})
}
declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>
}
}
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The one route every module's pages are mounted through: `/<org>/<workspace>/<project>/<module>/...` (F10).
//
// The loader is a server function: it checks the session, then runs the module's `load` (`modules/pages.ts`)
// with the request's own caller, messages and query string, and returns plain data. The component renders the module's
// `View` with that data, inside the project route's transport and the organization route's <main>. A new
// module therefore adds no route file here. A segment no module answers, a path the module does not know and
// a record of another tenant are the same not-found page.
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import { type ReactElement, Suspense } from "react"
import type { ModulePageData, ModulePageParams } from "../server/page"
import { projectPageViews } from "../ui/page-views"
import { RoutePending } from "../ui/route-pending"
const ROUTE = "/$org/$workspace/$project/$module/$"
const readModulePage = createServerFn({ method: "GET" })
.validator((params: ModulePageParams) => params)
.handler(async ({ data }): Promise<ModulePageData> => {
const { modulePageData, reported } = await import("../server/page")
return reported(ROUTE, () => modulePageData(data))
})
export const Route = createFileRoute("/$org/$workspace/$project/$module/$")({
loaderDeps: ({ search }) => ({ search }),
loader: ({ params, location }) =>
readModulePage({
data: {
org: params.org,
workspace: params.workspace,
project: params.project,
module: params.module,
...(params._splat ? { _splat: params._splat } : {}),
...(location.searchStr ? { search: location.searchStr } : {}),
},
}),
head: ({ loaderData }) => (loaderData?.title ? { meta: [{ title: loaderData.title }] } : {}),
component: ModuleRoute,
})
function ModuleRoute(): ReactElement {
const { org, workspace, project } = Route.useParams()
const page = Route.useLoaderData()
const View = projectPageViews.viewOf(page.module)
return (
<Suspense fallback={<RoutePending />}>
<View
key={`${org}:${workspace}:${project}:${page.module}:${page.segments.join("/")}`}
slugs={{ org, workspace, project }}
segments={page.segments}
locale={page.locale}
data={page.data}
/>
</Suspense>
)
}
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// One document: its extracted lines, each with where it was read, and the reviewer's decisions.
// Approve, edit and reject are procedure calls from the client component; nothing is exported
// until a line is approved, and the export is the last control on the page. The document is live: the
// loader's answer is the seed, the job's progress arrives through the live stream, and a decision reads it
// again, so the header, the counts and the export follow without a reload.
import { documentHeader, ExportButton, LineReview, useLiveDocument } from "@app/modules/documents/ui"
import { Page as PageFrame, PageHeader, PageSection } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { DocumentData, DocumentParams } from "../server/org-pages"
const ROUTE = "/$org/$workspace/$project/documents/$fileId"
const read = createServerFn({ method: "GET" })
.validator((params: DocumentParams) => params)
.handler(async ({ data }): Promise<DocumentData> => {
const [{ reported }, { documentData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => documentData(data))
})
export const Route = createFileRoute("/$org/$workspace/$project/documents/$fileId")({
loader: ({ params }) =>
read({
data: {
org: params.org,
workspace: params.workspace,
project: params.project,
fileId: params.fileId,
},
}),
head: ({ loaderData }) => (loaderData?.title ? { meta: [{ title: loaderData.title }] } : {}),
component: Page,
})
function Page(): ReactElement {
const { fileId, ...slugs } = Route.useParams()
const data = Route.useLoaderData()
const live = useLiveDocument(slugs, fileId, { document: data.document, lines: data.lines })
const header = documentHeader(live.document, data.header, data.locale)
return (
<PageFrame>
<PageHeader
title={data.title}
description={header.status}
breadcrumb={<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/documents`}>{data.back}</a>}
actions={
<ExportButton slugs={slugs} fileId={fileId} approved={live.document.approved} labels={data.exportLabels} />
}
>
<span>{header.counts}</span>
{header.waiting ? <span role="status">{header.waiting}</span> : null}
</PageHeader>
{header.skipped ? (
<PageSection title={header.skipped.title}>
<ul>
{header.skipped.pages.map((skipped) => (
<li key={skipped.page}>{skipped.text}</li>
))}
</ul>
</PageSection>
) : null}
<LineReview
slugs={slugs}
fileId={fileId}
lines={live.lines}
labels={data.review}
locale={data.locale}
onDecided={live.refetch}
/>
</PageFrame>
)
}
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The project's documents: upload one, and see where each is in being read and reviewed. Every
// read is a procedure through the same caller the API uses; the upload is a client form that
// calls the files module's own procedure (spec §6.2: one API path). The list is live: the loader's
// documents are the seed, and the job's progress reaches every member's open page without a reload.
import { DocumentList, UploadForm, useLiveDocuments } from "@app/modules/documents/ui"
import { downloadHref } from "@conusai-kit/ui/download"
import { FormPanel, Page as PageFrame, PageHeader } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { DocumentsData, ProjectParams } from "../server/org-pages"
const ROUTE = "/$org/$workspace/$project/documents/"
const read = createServerFn({ method: "GET" })
.validator((params: ProjectParams) => params)
.handler(async ({ data }): Promise<DocumentsData> => {
const [{ reported }, { documentsData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => documentsData(data))
})
export const Route = createFileRoute("/$org/$workspace/$project/documents/")({
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace, project: params.project } }),
head: ({ loaderData }) => (loaderData?.title ? { meta: [{ title: loaderData.title }] } : {}),
component: Page,
})
function Page(): ReactElement {
const slugs = Route.useParams()
const data = Route.useLoaderData()
const live = useLiveDocuments(slugs, data.documents)
// As the shell formats a date (`i18n.date`), in the locale the server rendered in.
const day = new Intl.DateTimeFormat(data.locale).format
return (
<PageFrame>
<PageHeader
title={data.title}
description={data.intro}
actions={
<FormPanel label={data.upload.heading}>
{() => <UploadForm slugs={slugs} accept={data.accept} labels={data.upload} onUploaded={live.refetch} />}
</FormPanel>
}
/>
<DocumentList
documents={live.documents}
hrefFor={(fileId) => `/${slugs.org}/${slugs.workspace}/${slugs.project}/documents/${fileId}`}
downloadHrefFor={(fileId) => downloadHref("files.file.download", { ...slugs, fileId })}
labels={data.list}
formatDate={(iso) => day(new Date(iso))}
/>
</PageFrame>
)
}
@@ -0,0 +1,54 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A project's home: what it is, and the pages of the modules that work in it (spec §3.1, §4.1).
// The entries come from the modules' manifests (src/server/project-entries.ts), so a module added
// tomorrow appears here with no edit to this file.
import { Page as PageFrame, PageHeader, PageSection, PageState } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { ProjectHomeData, ProjectParams } from "../server/org-pages"
import { projectHref } from "../ui/projects"
const ROUTE = "/$org/$workspace/$project/"
const read = createServerFn({ method: "GET" })
.validator((params: ProjectParams) => params)
.handler(async ({ data }): Promise<ProjectHomeData> => {
const [{ reported }, { projectHomeData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => projectHomeData(data))
})
export const Route = createFileRoute("/$org/$workspace/$project/")({
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace, project: params.project } }),
head: ({ loaderData }) => ({ meta: loaderData ? [{ title: loaderData.title }] : [] }),
component: Page,
})
function Page(): ReactElement {
const { org, workspace, project } = Route.useParams()
const data = Route.useLoaderData()
return (
<PageFrame>
<PageHeader title={data.title} description={data.module}>
{data.archived ? <span role="status">{data.archived}</span> : null}
</PageHeader>
<PageSection title={data.work}>
{data.entries.length ? (
<ul>
{data.entries.map((entry) => (
<li key={entry.href}>
<a href={projectHref(org, workspace, project, entry)}>{entry.label}</a>
</li>
))}
</ul>
) : (
<PageState title={data.noSections} />
)}
</PageSection>
</PageFrame>
)
}
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The project route: the project has to exist in this workspace of this organization, and a project of
// another tenant, an unknown slug and a project the person has no place in are one 404, answered before the
// page renders (`projectLayoutData`, spec §3.3).
//
// Every project page -- a module's, mounted through the one catch-all route, and the documents pages --
// renders inside the app's transport with the person's permissions in the project, read once here and
// handed to the browser as the seed of `useCan` and `<Can>` (`@conusai-kit/ui/data`). The sections of a project
// are tabs above its page; the frame reads them, and the project's name, from this route's loader data.
import { createFileRoute, Outlet } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { ProjectParams } from "../server/org-pages"
import type { ProjectLayoutData } from "../server/page"
import { AppTransport } from "../ui/transport"
const ROUTE = "/$org/$workspace/$project"
const readProject = createServerFn({ method: "GET" })
.validator((params: ProjectParams) => params)
.handler(async ({ data }): Promise<ProjectLayoutData> => {
const { projectLayoutData, reported } = await import("../server/page")
return reported(ROUTE, () => projectLayoutData(data))
})
export const Route = createFileRoute("/$org/$workspace/$project")({
loader: ({ params }) =>
readProject({ data: { org: params.org, workspace: params.workspace, project: params.project } }),
component: ProjectLayout,
})
function ProjectLayout(): ReactElement {
const { scope, seed } = Route.useLoaderData()
return (
<AppTransport key={`${scope.org}/${scope.workspace}/${scope.project}`} permissions={{ scope, seed }}>
<Outlet />
</AppTransport>
)
}
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A workspace: its projects, and a form to create another (spec §3.1). The projects are listed
// through `tenancy.project.list`, which reads them inside the workspace's own scope -- a workspace of
// another organization, or one the person has no place in, is the not-found page.
import { moduleSelected } from "@app/modules/inclusion"
import { GuidesSetupCard } from "@conusai-kit/guides/panel" // kit:guides
import { FormPanel, Page as PageFrame, PageHeader, PageSection, ResourceList } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { WorkspaceHomeData, WorkspaceParams } from "../server/org-pages"
import { useFrameRouter } from "../ui/frame-router"
import { ProcedureForm } from "../ui/procedure-form"
import { projectHref } from "../ui/projects"
const ROUTE = "/$org/$workspace/"
const read = createServerFn({ method: "GET" })
.validator((params: WorkspaceParams) => params)
.handler(async ({ data }): Promise<WorkspaceHomeData> => {
const [{ reported }, { workspaceHomeData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => workspaceHomeData(data))
})
export const Route = createFileRoute("/$org/$workspace/")({
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace } }),
head: ({ loaderData }) => ({ meta: loaderData ? [{ title: loaderData.title }] : [] }),
component: Page,
})
function Page(): ReactElement {
const { org, workspace } = Route.useParams()
const data = Route.useLoaderData()
const create = data.create
const { navigate } = useFrameRouter()
return (
<PageFrame>
<PageHeader
title={data.title}
description={data.description}
actions={
create ? (
<FormPanel label={create.title}>
{(complete) => (
<ProcedureForm
procedure="tenancy.project.create"
onComplete={complete}
hidden={{ org, workspace }}
submitLabel={create.submit}
fields={create.fields}
/>
)}
</FormPanel>
) : undefined
}
/>
{/* kit:guides:begin */}
{moduleSelected("guides") && <GuidesSetupCard locale={data.locale} scope={{ org, workspace }} />}
{/* kit:guides:end */}
<PageSection title={data.projectsHeading}>
<ResourceList
label={data.projectsHeading}
columns={[
{ id: "name", header: data.columnName, wrap: true },
{ id: "moduleKey", header: data.columnModule },
]}
data={data.projects}
rowId={(project) => project.slug}
rowHref={(project) => projectHref(org, workspace, project.slug)}
navigate={navigate}
rowLabel={(project) => project.name}
empty={{ title: data.none }}
/>
</PageSection>
</PageFrame>
)
}
@@ -0,0 +1,63 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The one route of the modules' pages above a project: `/<org>/<workspace>/modules/<module>/...` (F10).
//
// `modules` is a segment no project can take (RESERVED_PROJECT_SLUGS, test/reserved-routes.test.ts): a bare
// `/<org>/<workspace>/<module>` would be a project's slug. The loader is a server function, as on the project
// route: it checks the session and the workspace, then runs the module's `load` (`workspacePages` of
// `modules/pages.ts`) with the request's own caller, messages and query string. A new page adds no route file.
// A page the module placed in Settings (`placement: "settings"`) renders as that tab of Settings.
import { createFileRoute, useLocation } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import { type ReactElement, Suspense } from "react"
import type { WorkspacePageData, WorkspacePageParams } from "../server/page"
import { workspacePageViews } from "../ui/page-views"
import { RoutePending } from "../ui/route-pending"
import { isSettingsPage, SettingsFrame, useSettingsSections } from "../ui/settings-frame"
import { AppTransport } from "../ui/transport"
const ROUTE = "/$org/$workspace/modules/$module/$"
const readWorkspacePage = createServerFn({ method: "GET" })
.validator((params: WorkspacePageParams) => params)
.handler(async ({ data }): Promise<WorkspacePageData> => {
const { reported, workspacePageData } = await import("../server/page")
return reported(ROUTE, () => workspacePageData(data))
})
export const Route = createFileRoute("/$org/$workspace/modules/$module/$")({
loaderDeps: ({ search }) => ({ search }),
loader: ({ params, location }) =>
readWorkspacePage({
data: {
org: params.org,
workspace: params.workspace,
module: params.module,
...(params._splat ? { _splat: params._splat } : {}),
...(location.searchStr ? { search: location.searchStr } : {}),
},
}),
head: ({ loaderData }) => (loaderData?.title ? { meta: [{ title: loaderData.title }] } : {}),
component: WorkspaceModuleRoute,
})
function WorkspaceModuleRoute(): ReactElement {
const { org, workspace } = Route.useParams()
const page = Route.useLoaderData()
const View = workspacePageViews.viewOf(page.module)
const view = (
<AppTransport permissions={{ scope: page.scope, seed: page.seed }}>
<Suspense fallback={<RoutePending />}>
<View
key={`${org}:${workspace}:${page.module}:${page.segments.join("/")}`}
slugs={{ org, workspace }}
segments={page.segments}
locale={page.locale}
data={page.data}
/>
</Suspense>
</AppTransport>
)
const inSettings = isSettingsPage(useSettingsSections(), useLocation().pathname)
return inSettings ? <SettingsFrame>{view}</SettingsFrame> : view
}
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import { Page, PageHeader, PageSection, ResourceList } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { WorkspaceHomeData, WorkspaceParams } from "../server/org-pages"
import { useFrameRouter } from "../ui/frame-router"
import { ProcedureForm } from "../ui/procedure-form"
import { projectHref } from "../ui/projects"
const read = createServerFn({ method: "GET" })
.validator((params: WorkspaceParams) => params)
.handler(async ({ data }): Promise<WorkspaceHomeData> => {
const [{ reported }, { workspaceHomeData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported("/$org/$workspace/modules/", () => workspaceHomeData(data))
})
export const Route = createFileRoute("/$org/$workspace/modules/")({
validateSearch: (search: Record<string, unknown>): { create?: true } =>
search.create === true || search.create === 1 || search.create === "1" ? { create: true } : {},
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace } }),
head: ({ loaderData }) => ({ meta: loaderData ? [{ title: loaderData.projectsHeading }] : [] }),
component: Projects,
})
function Projects() {
const { org, workspace } = Route.useParams()
const data = Route.useLoaderData()
const search = Route.useSearch()
const { navigate } = useFrameRouter()
const create = search.create ? data.create : undefined
return (
<Page>
<PageHeader title={data.projectsHeading} />
{create ? (
<PageSection title={create.title}>
<ProcedureForm
key={`${org}/${workspace}`}
procedure="tenancy.project.create"
hidden={{ org, workspace }}
submitLabel={create.submit}
fields={create.fields}
after={projectHref(org, workspace, "{slug}")}
/>
</PageSection>
) : data.create ? (
<a href={`/${org}/${workspace}/modules?create=true`}>{data.create.title}</a>
) : null}
<ResourceList
label={data.projectsHeading}
columns={[
{ id: "name", header: data.columnName, wrap: true },
{ id: "moduleKey", header: data.columnModule },
]}
data={data.projects}
rowId={(project) => project.slug}
rowHref={(project) => projectHref(org, workspace, project.slug)}
rowLabel={(project) => project.name}
navigate={navigate}
empty={{ title: data.none }}
/>
</Page>
)
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The workspace route: the workspace has to be one this person can see, and the page says 404 before it
// renders if it is not (`requireWorkspace`, spec §3.3). Its loader data is what the frame shows of the
// workspace (`$org.tsx`): its name and the sidebar at this scope, the modules' `ui.nav` with `scope:
// "workspace"`, filtered on the server by the person's permissions in the workspace.
import { createFileRoute, Outlet } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { WorkspaceLayoutData, WorkspaceParams } from "../server/org-pages"
const ROUTE = "/$org/$workspace"
const readWorkspace = createServerFn({ method: "GET" })
.validator((params: WorkspaceParams) => params)
.handler(async ({ data }): Promise<WorkspaceLayoutData> => {
const [{ reported }, { requireWorkspace }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => requireWorkspace(data))
})
export const Route = createFileRoute("/$org/$workspace")({
loader: ({ params }) => readWorkspace({ data: { org: params.org, workspace: params.workspace } }),
component: Outlet,
})
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The access audit (kit-tenancy's access pages) is for whoever manages the organization's members. Asked in
// the loader, before anything renders, so a member without it gets a real 403 and a stranger a real 404.
import { accessAuditPage } from "@conusai-kit/tenancy/ui"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { AccessAuditData, OrgParams } from "../server/org-pages"
import { RoutePending } from "../ui/route-pending"
import { tenancyStyles } from "../ui/tenancy-styles"
const ROUTE = "/$org/access-audit"
const read = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<AccessAuditData> => {
const [{ reported }, { accessAuditData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => accessAuditData(data))
})
export const Route = createFileRoute("/$org/_settings/access-audit")({
loader: ({ params }) => read({ data: { org: params.org } }),
head: tenancyStyles,
pendingComponent: RoutePending,
component: Page,
})
function Page(): ReactElement {
return <accessAuditPage.View data={Route.useLoaderData()} />
}
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The audit viewer (spec §9.4), a tab of Settings, for whoever holds `tenancy.audit.read`: the loader asks before
// anything renders, so a member without it gets a real 403 and a stranger a real 404 (src/server/org-pages.ts).
import { PageSection, PageState } from "@conusai-kit/ui/patterns"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@conusai-kit/ui/primitives"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { AuditData, OrgParams } from "../server/org-pages"
import { RoutePending } from "../ui/route-pending"
const ROUTE = "/$org/audit"
const read = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<AuditData> => {
const [{ reported }, { auditData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
return reported(ROUTE, () => auditData(data))
})
export const Route = createFileRoute("/$org/_settings/audit")({
loader: ({ params }) => read({ data: { org: params.org } }),
pendingComponent: RoutePending,
component: Page,
})
function Page(): ReactElement {
const data = Route.useLoaderData()
return (
<PageSection title={data.title}>
{data.rows.length === 0 ? (
<PageState title={data.empty} />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">{data.when}</TableHead>
<TableHead scope="col">{data.action}</TableHead>
<TableHead scope="col">{data.outcome}</TableHead>
<TableHead scope="col">{data.resource}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.when}</TableCell>
<TableCell>{row.action}</TableCell>
<TableCell>{row.outcome}</TableCell>
<TableCell>{row.resource}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</PageSection>
)
}
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's brand: name, colours and logos, with a preview of both themes and the contrast
// each colour has there. Every member sees it; an administrator changes it (`branding.brand.write`,
// which the kernel checks before a handler runs), and a member without that permission is shown the
// same page read-only.
import { BrandForm } from "@app/modules/branding/ui"
import { PageSection } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { BrandingData, OrgParams } from "../server/org-pages"
const ROUTE = "/$org/branding"
const read = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<BrandingData> => {
const [{ reported }, { brandingData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => brandingData(data))
})
export const Route = createFileRoute("/$org/_settings/branding")({
loader: ({ params }) => read({ data: { org: params.org } }),
component: Branding,
})
function Branding(): ReactElement {
const { org } = Route.useParams()
const data = Route.useLoaderData()
return (
<PageSection title={data.title} description={data.intro}>
<BrandForm
org={org}
brand={data.brand}
canEdit={data.canEdit}
defaultPrimary={data.defaultPrimary}
labels={data.labels}
/>
</PageSection>
)
}
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's invitations (src/server/org-pages.ts): the invite form, the link it makes and the pending
// invitations, kit-tenancy's island. Who may see them is asked in the loader, before anything renders, so a
// member without `tenancy.invitation.list` gets a real 403 and a stranger a real 404. An invitation link opens
// `/invitations/<org>/<token>`.
import { invitationsPage } from "@conusai-kit/tenancy/ui"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { InvitationsData, OrgParams } from "../server/org-pages"
import { RoutePending } from "../ui/route-pending"
import { tenancyStyles } from "../ui/tenancy-styles"
const ROUTE = "/$org/invitations"
const read = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<InvitationsData> => {
const [{ reported }, { invitationsData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => invitationsData(data))
})
export const Route = createFileRoute("/$org/_settings/invitations")({
loader: ({ params }) => read({ data: { org: params.org } }),
head: tenancyStyles,
pendingComponent: RoutePending,
component: Page,
})
function Page(): ReactElement {
return <invitationsPage.View data={Route.useLoaderData()} />
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A project's members: a project role of their own, set or cleared with `tenancy.project.set-member`
// (kit-tenancy's access pages); without one, the workspace's or the organization's applies.
import { membersPage } from "@conusai-kit/tenancy/ui"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { MembersData, MembersParams } from "../server/org-pages"
const ROUTE = "/$org/members/$workspace/$project"
const read = createServerFn({ method: "GET" })
.validator((params: MembersParams) => params)
.handler(async ({ data }): Promise<MembersData> => {
const [{ reported }, { membersData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
return reported(ROUTE, () => membersData(data))
})
export const Route = createFileRoute("/$org/_settings/members/$workspace/$project")({
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace, project: params.project } }),
component: Page,
})
function Page(): ReactElement {
return <membersPage.View data={Route.useLoaderData()} />
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A workspace's members: the role each holds there of their own, or the organization's (kit-tenancy's
// access pages). A workspace slug that is not there, or not visible, is the not-found page.
import { membersPage } from "@conusai-kit/tenancy/ui"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { MembersData, MembersParams } from "../server/org-pages"
const ROUTE = "/$org/members/$workspace/"
const read = createServerFn({ method: "GET" })
.validator((params: MembersParams) => params)
.handler(async ({ data }): Promise<MembersData> => {
const [{ reported }, { membersData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
return reported(ROUTE, () => membersData(data))
})
export const Route = createFileRoute("/$org/_settings/members/$workspace/")({
loader: ({ params }) => read({ data: { org: params.org, workspace: params.workspace } }),
component: Page,
})
function Page(): ReactElement {
return <membersPage.View data={Route.useLoaderData()} />
}
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's members and the role each holds (kit-tenancy's access pages). Every change is
// a procedure call -- `tenancy.member.assign` -- so the ceilings, the last owner's protection and the
// audit event are kit-tenancy's, not this page's. A workspace's and a project's members are the pages
// below this one.
import { membersPage } from "@conusai-kit/tenancy/ui"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { MembersData, MembersParams } from "../server/org-pages"
const ROUTE = "/$org/members/"
const read = createServerFn({ method: "GET" })
.validator((params: MembersParams) => params)
.handler(async ({ data }): Promise<MembersData> => {
const [{ reported }, { membersData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
return reported(ROUTE, () => membersData(data))
})
export const Route = createFileRoute("/$org/_settings/members/")({
loader: ({ params }) => read({ data: { org: params.org } }),
component: Page,
})
function Page(): ReactElement {
return <membersPage.View data={Route.useLoaderData()} />
}
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The members pages (kit-tenancy's access pages, `@conusai-kit/tenancy/ui`) are for whoever holds
// `tenancy.member.read`. Asked in the loader, before anything renders, so a member without the permission
// gets a real 403 and a stranger a real 404 (src/server/guard.ts).
import { createFileRoute, Outlet } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { OrgParams } from "../server/org-pages"
import { RoutePending } from "../ui/route-pending"
import { tenancyStyles } from "../ui/tenancy-styles"
const ROUTE = "/$org/members"
const requireAccess = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<null> => {
const [{ reported }, { requireAccessSection }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => requireAccessSection({ ...data, section: "members" }))
})
export const Route = createFileRoute("/$org/_settings/members")({
loader: ({ params }) => requireAccess({ data: { org: params.org } }),
head: tenancyStyles,
pendingComponent: RoutePending,
component: Outlet,
})
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// One role: its permission matrix by module, and for a custom role its name, its permissions and its
// deletion with the role its members move to (kit-tenancy's access pages). A key that is not one of
// this organization's roles is the not-found page.
import { rolePage } from "@conusai-kit/tenancy/ui"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { RoleData, RoleParams } from "../server/org-pages"
const ROUTE = "/$org/roles/$role"
const read = createServerFn({ method: "GET" })
.validator((params: RoleParams) => params)
.handler(async ({ data }): Promise<RoleData> => {
const [{ reported }, { roleData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
return reported(ROUTE, () => roleData(data))
})
export const Route = createFileRoute("/$org/_settings/roles/$role")({
loader: ({ params }) => read({ data: { org: params.org, role: params.role } }),
component: Page,
})
function Page(): ReactElement {
return <rolePage.View data={Route.useLoaderData()} />
}
@@ -0,0 +1,27 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's roles: the built-in ones its modules define and its own custom ones, with the
// form that creates a role as a copy of another (kit-tenancy's access pages).
import { rolesPage } from "@conusai-kit/tenancy/ui"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { OrgParams, RolesData } from "../server/org-pages"
const ROUTE = "/$org/roles/"
const read = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<RolesData> => {
const [{ reported }, { rolesData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
return reported(ROUTE, () => rolesData(data))
})
export const Route = createFileRoute("/$org/_settings/roles/")({
loader: ({ params }) => read({ data: { org: params.org } }),
component: Page,
})
function Page(): ReactElement {
return <rolesPage.View data={Route.useLoaderData()} />
}
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The roles pages (kit-tenancy's access pages) are for whoever holds `tenancy.role.read`; changing a role
// takes `tenancy.role.manage`, which the procedures check. Asked in the loader, before anything renders, so
// a member without it gets a real 403 and a stranger a real 404.
import { createFileRoute, Outlet } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { OrgParams } from "../server/org-pages"
import { RoutePending } from "../ui/route-pending"
import { tenancyStyles } from "../ui/tenancy-styles"
const ROUTE = "/$org/roles"
const requireAccess = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<null> => {
const [{ reported }, { requireAccessSection }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => requireAccessSection({ ...data, section: "roles" }))
})
export const Route = createFileRoute("/$org/_settings/roles")({
loader: ({ params }) => requireAccess({ data: { org: params.org } }),
head: tenancyStyles,
pendingComponent: RoutePending,
component: Outlet,
})
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The person's own account, as the Personal section of Settings: the same data and panels as `/settings/account`,
// the page a person with no organization has.
import { FactList, PageSection } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { AccountData } from "../server/account-pages"
import { AccountPanels } from "../ui/account"
const ROUTE = "/$org/settings/account"
const readAccount = createServerFn({ method: "GET" }).handler(async (): Promise<AccountData> => {
const [{ reported }, { accountData }] = await Promise.all([
import("../server/page"),
import("../server/account-pages"),
])
return reported(ROUTE, () => accountData())
})
export const Route = createFileRoute("/$org/_settings/settings/account")({
loader: () => readAccount(),
component: Account,
})
function Account(): ReactElement {
const { page, displayName, email, exportLabels, analyticsLabels, deletion } = Route.useLoaderData()
return (
<PageSection title={page.title} description={page.intro}>
<FactList
facts={[
{ label: page.name, value: displayName },
{ label: page.email, value: email },
]}
/>
<AccountPanels exportLabels={exportLabels} analyticsLabels={analyticsLabels} deletion={deletion} />
</PageSection>
)
}
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's own settings, for whoever holds `tenancy.org.update`: a member without it gets the
// forbidden page, a stranger the not-found page. Its name and default language are saved here, through
// `tenancy.org.update`; workspaces are created on their own page (`/{org}/workspaces`).
import { SettingsSection } from "@app/modules/knowledge/ui/settings-section" // kit:knowledge
import { FactList, PageSection } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { OrgParams, OrgSettingsData } from "../server/org-pages"
import { OrganizationSettings } from "../ui/organization-settings"
const ROUTE = "/$org/settings"
const read = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<OrgSettingsData> => {
const [{ reported }, { orgSettingsData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => orgSettingsData(data))
})
export const Route = createFileRoute("/$org/_settings/settings/")({
loader: ({ params }) => read({ data: { org: params.org } }),
component: Page,
})
function Page(): ReactElement {
const { org } = Route.useParams()
const data = Route.useLoaderData()
return (
<>
<OrganizationSettings org={org} organization={data.organization} labels={data.organizationLabels} />
<PageSection variant="card" title={data.title}>
<FactList
facts={[
{ label: data.slugLabel, value: data.slug },
{ label: data.statusLabel, value: data.status },
]}
/>
</PageSection>
{/* kit:knowledge:begin */}
{data.knowledge && <SettingsSection org={org} initial={data.knowledge} labels={data.labels} />}
{/* kit:knowledge:end */}
</>
)
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Settings: one page, a row of tabs (the organization's sections, the workspace's, the person's account), shown by
// permission. This route has no path of its own: each section keeps its address and renders inside `SettingsFrame`.
// Reflection only: every section's loader asks again, so a deep link to one the person lacks is still that loader's 403.
import { createFileRoute, Outlet } from "@tanstack/react-router"
import type { ReactElement } from "react"
import { SettingsFrame } from "../ui/settings-frame"
export const Route = createFileRoute("/$org/_settings")({ component: Settings })
function Settings(): ReactElement {
return (
<SettingsFrame>
<Outlet />
</SettingsFrame>
)
}
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The workspaces of the organization, and a form to create one (spec §3.1). A workspace is where
// projects live and where a restricted group of people works apart from the rest of the
// organization; creating one is for administrators (`tenancy.workspace.manage`), so the form is shown
// to them and the kernel refuses everybody else regardless.
import { FormPanel, PageSection, ResourceList } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { OrgParams, WorkspacesData } from "../server/org-pages"
import { useFrameRouter } from "../ui/frame-router"
import { ProcedureForm } from "../ui/procedure-form"
const ROUTE = "/$org/workspaces"
const read = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<WorkspacesData> => {
const [{ reported }, { workspacesData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => workspacesData(data))
})
export const Route = createFileRoute("/$org/_settings/workspaces")({
loader: ({ params }) => read({ data: { org: params.org } }),
component: Page,
})
function Page(): ReactElement {
const { org } = Route.useParams()
const data = Route.useLoaderData()
const create = data.create
const { navigate } = useFrameRouter()
return (
<PageSection
title={data.title}
actions={
create ? (
<FormPanel label={create.title}>
{(complete) => (
<ProcedureForm
procedure="tenancy.workspace.create"
onComplete={complete}
hidden={{ org }}
submitLabel={create.submit}
fields={create.fields}
/>
)}
</FormPanel>
) : undefined
}
>
<ResourceList
label={data.title}
columns={[
{
id: "name",
header: data.name,
wrap: true,
cell: (workspace) => `${workspace.name}${workspace.isDefault ? ` (${workspace.isDefault})` : ""}`,
},
{ id: "visibility", header: data.visibility },
]}
data={data.rows}
rowId={(workspace) => workspace.slug}
rowHref={(workspace) => `/${org}/${workspace.slug}`}
navigate={navigate}
rowLabel={(workspace) => workspace.name}
empty={{ title: data.none }}
/>
</PageSection>
)
}
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// An organization's logo, served to its members from this app's own origin (src/server/brand-routes.ts).
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/$org/brand/logo/$variant")({
server: {
handlers: {
GET: async ({ request, params }) =>
(await import("../server/brand-routes")).organizationLogo(request, params.org, params.variant),
},
},
})
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// An organization's share image (src/server/brand-routes.ts): only a member draws it.
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/$org/brand/og")({
server: {
handlers: {
GET: async ({ params }) => (await import("../server/brand-routes")).organizationShareImage(params.org),
},
},
})
+58
View File
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's workspaces and the projects of each (spec §3.1).
import { Page as PageFrame, PageHeader, ResourceList } from "@conusai-kit/ui/patterns"
import { Button } from "@conusai-kit/ui/primitives"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { OrgHomeData, OrgParams } from "../server/org-pages"
import { useFrameRouter } from "../ui/frame-router"
const ROUTE = "/$org/"
const read = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<OrgHomeData> => {
const [{ reported }, { orgHomeData }] = await Promise.all([import("../server/page"), import("../server/org-pages")])
return reported(ROUTE, () => orgHomeData(data))
})
export const Route = createFileRoute("/$org/")({
loader: ({ params }) => read({ data: { org: params.org } }),
component: Page,
})
function Page(): ReactElement {
const { org } = Route.useParams()
const data = Route.useLoaderData()
const { navigate } = useFrameRouter()
return (
<PageFrame>
<PageHeader
title={data.title}
description={data.description}
actions={
data.createWorkspace ? (
<Button asChild>
<a href={`/${org}/workspaces`}>{data.createWorkspace}</a>
</Button>
) : undefined
}
/>
<ResourceList
label={data.title}
columns={[
{ id: "name", header: data.name, wrap: true },
{ id: "projectCount", header: data.projects },
]}
data={data.workspaces}
rowId={(workspace) => workspace.slug}
rowHref={(workspace) => `/${org}/${workspace.slug}`}
rowLabel={(workspace) => workspace.name}
navigate={navigate}
empty={{ title: data.none }}
/>
</PageFrame>
)
}
+191
View File
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// kit:custom-ui the organization's logo per theme, as the frame's mark
// The organization route. The scope is resolved on the server, once, by calling an organization-scoped
// procedure: a person who is not a member gets the same NotFound a stranger does, before anything renders
// (spec §3.3, §6.4; `orgLayoutData`).
//
// It is where every page of an organization gets the app frame (`AppFrame` of `@conusai-kit/ui/frame`), and
// it stays mounted while the person moves between them: an open right panel stays open. The routes below hand
// the frame what only they know (the workspace, the project); each is read here from its route's loader data,
// so the frame has one place. The organization's own brand (spec §10.3) goes on <body>, from the root route.
//
// The frame renders the <main> every page below this route fills, so an organization page returns its content
// and not a second landmark. An organization's own page (Settings) is shown in the workspace the person was last in:
// its sidebar, and the breadcrumb starting from it. Opening a workspace's page remembers it (`app_workspace`).
import { moduleSelected } from "@app/modules/inclusion"
import { useNotifications } from "@conusai-kit/notifications/client"
import { AppFrame, type AppFrameProps, type FrameScope, type ModuleManifest } from "@conusai-kit/ui/frame"
import { Alert, AlertDescription } from "@conusai-kit/ui/primitives"
import { createFileRoute, Outlet, useMatch } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement, ReactNode } from "react"
import type { OrgLayoutData, OrgParams, TenantLogoData } from "../server/org-pages"
import { useAgent, useShownRecord } from "../ui/agent" // kit:agent
import { ClientModules } from "../ui/client-modules"
import type { ShellData } from "../ui/document"
import { useFrameLayout } from "../ui/frame-layout"
import { useFrameLinks, useFrameNavigationFocus, useFrameRouter } from "../ui/frame-router"
import { helpFor } from "../ui/help"
import { isSettingsPage } from "../ui/settings-frame"
import { usePersonMenu } from "../ui/shell-controls"
import { SupportCommand } from "../ui/support-command" // kit:support
import { useShell } from "../ui/use-shell"
import { useRememberedWorkspace, WorkspaceContext } from "../ui/workspace-context"
const ROUTE = "/$org"
const readOrganization = createServerFn({ method: "GET" })
.validator((params: OrgParams) => params)
.handler(async ({ data }): Promise<OrgLayoutData> => {
const [{ reported }, { orgLayoutData }] = await Promise.all([
import("../server/page"),
import("../server/org-pages"),
])
return reported(ROUTE, () => orgLayoutData(data))
})
export const Route = createFileRoute("/$org")({
loader: ({ params }) => readOrganization({ data: { org: params.org } }),
component: OrgLayout,
})
function TenantLogo({ className, logo }: { className: string; logo: TenantLogoData }): ReactElement {
return <img className={className} src={logo.src} srcSet={logo.srcSet} alt="" />
}
/** The organization's logo in each theme, or the app's own. */
function BrandMark({ data, brand }: { data: OrgLayoutData; brand: ShellData["brand"] }): ReactElement {
return (
<>
{data.logoDark ? (
<TenantLogo className="app-logo app-logo-dark" logo={data.logoDark} />
) : (
<img className="app-logo app-logo-dark" src={brand.logo.dark} alt="" />
)}
{data.logoLight ? (
<TenantLogo className="app-logo app-logo-light" logo={data.logoLight} />
) : (
<img className="app-logo app-logo-light" src={brand.logo.light} alt="" />
)}
</>
)
}
/** The route's data is translated and already filtered by the person's permissions in each scope. */
const translated = (key: string): string => key
function Frame({ shell, children }: { shell: ShellData; children: ReactNode }): ReactElement {
const { org } = Route.useParams()
const data = Route.useLoaderData()
const router = useFrameRouter()
const layout = useFrameLayout(shell)
const { menu, problem } = usePersonMenu(shell, data.accountHref)
const notifications = useNotifications({
org,
user: data.client.user,
locale: shell.locale,
organizationName: data.name,
enabled: data.permissions.includes("notifications.inbox.read"),
})
useFrameLinks(router)
useFrameNavigationFocus()
const help = helpFor(data.client, shell.help)
const panels: Pick<AppFrameProps, "agent" | "help" | "onRecordChange"> = help.any
? { help: () => help.occupant() }
: {}
const opened = useMatch({ from: "/$org/$workspace", shouldThrow: false })?.loaderData
const remembered = useRememberedWorkspace(org, opened, data.workspaces)
// Settings is shown in the workspace; the organization's home is where a workspace is chosen, so it has none.
const inSettings = useMatch({ from: "/$org/_settings", shouldThrow: false }) !== undefined
const workspace = opened ?? (inSettings ? (remembered ?? data.workspace) : undefined)
const settingsSections = [...data.settings.sections, ...(workspace?.settings ?? [])]
const project = useMatch({ from: "/$org/$workspace/$project", shouldThrow: false })?.loaderData
const scope: FrameScope = {
organization: { id: org, label: data.name },
...(workspace ? { workspace: workspace.workspace } : {}),
...(project ? { project: project.project } : {}),
}
// On an organization's own page its own permissions: the sidebar it borrows from the workspace is already filtered.
const permissions = project?.seed.permissions ?? opened?.permissions ?? data.permissions
// kit:agent:begin
const [, , , , section] = router.pathname.split("/")
// The row the side panel shows (a source viewer's) before the record the address names: neither reloads the page.
const [shown, setShown] = useShownRecord()
const record = shown ?? router.search.get("record")
panels.onRecordChange = setShown
const agent = useAgent({
// Every page of a workspace: the workspace's own conversation, or the project's when one is open.
...(opened
? {
slugs: { org, workspace: opened.workspace.id, ...(project ? { project: project.project.id } : {}) },
workspaceName: opened.workspace.label,
...(project ? { projectName: project.project.label } : {}),
}
: {}),
user: data.client.user,
locale: shell.locale,
permissions,
...(section ? { resource: section } : {}),
...(record ? { recordId: record } : {}),
})
if (moduleSelected("agent") && agent) panels.agent = agent
// kit:agent:end
const modules: ModuleManifest[] = [
{ id: "pages", ui: { nav: [...(workspace?.nav ?? [data.home])] } },
{ id: "project", ui: { nav: [...(project?.tabs ?? [])] } },
]
return (
<WorkspaceContext.Provider value={workspace}>
<AppFrame
brand={<BrandMark data={data} brand={shell.brand} />}
scope={scope}
organizations={data.organizations}
workspaces={data.workspaces}
{...(data.workspaceCreateHref ? { workspaceCreateHref: data.workspaceCreateHref } : {})}
{...(data.organizationCreateHref ? { organizationCreateHref: data.organizationCreateHref } : {})}
{...(opened?.projectNavigation ? { projectNavigation: opened.projectNavigation } : {})}
modules={modules}
permissions={permissions}
person={{ ...data.person, menu }}
settingsHref={data.settingsHref}
settingsLabel={data.settingsHref === data.accountHref ? shell.labels.account : data.settingsLabel}
settingsActive={inSettings || isSettingsPage(settingsSections, router.pathname)}
router={router}
t={translated}
{...layout}
{...panels}
{...(notifications ? { notifications } : {})}
{...(problem
? {
footer: (
<Alert variant="destructive">
<AlertDescription>{problem}</AlertDescription>
</Alert>
),
}
: {})}
>
{/* kit:support:begin */}
{moduleSelected("support") && <SupportCommand open={() => help.occupant("support")} />}
{/* kit:support:end */}
{children}
</AppFrame>
</WorkspaceContext.Provider>
)
}
function OrgLayout(): ReactElement | null {
const shell = useShell()
const { client } = Route.useLoaderData()
if (!shell) return null
return (
// The enabled modules' browser providers (an in-app guide's overlay) go beside the page, and the app's transport
// around it: the kit's access pages, the brand form and whatever opens in the right panel read through it.
<ClientModules context={client}>
<Frame shell={shell}>
<Outlet />
</Frame>
</ClientModules>
)
}
+178
View File
@@ -0,0 +1,178 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The root route: the document, the brand layer and the app shell (spec §10.2), on TanStack Start (F10).
//
// Its loader is a server function: it reads the session and translates every label the shell shows
// (`shellData`, src/server/page.ts), so the browser receives strings, never a catalogue. The brand's
// `--brand-*` declarations are a style attribute on <html> (src/ui/document.tsx), the layer a tenant brand
// overrides at runtime without any stylesheet being rebuilt.
//
// Not found and forbidden are one component: guard.ts's forbidden page is a not-found with
// `data.forbidden`, answered 403 (Start has no `forbidden()`).
import moduleCss from "@app/modules/styles.css?url"
import { Button, UiLabelsProvider } from "@conusai-kit/ui/primitives"
import {
createRootRoute,
type ErrorComponentProps,
HeadContent,
type NotFoundRouteProps,
Outlet,
Scripts,
useMatch,
} from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement, ReactNode } from "react"
import type { StatusPageData } from "../server/guard"
import { BoundaryMessages, RootErrorView } from "../ui/boundary"
import { brandHead, brandMetadata, brandTokensOnly, Document, type DocumentProps, type ShellData } from "../ui/document"
import appCss from "../ui/globals.css?url"
import { useHydratedSignal } from "../ui/hydrated"
import { LanguageSwitch, ThemeSwitch } from "../ui/shell-controls"
import { SignOut } from "../ui/sign-out"
import { StateView } from "../ui/state-view"
import { useShell } from "../ui/use-shell"
const readShell = createServerFn({ method: "GET" }).handler(async (): Promise<ShellData> => {
const { reported, shellData } = await import("../server/page")
return reported("__root__", () => shellData())
})
export const Route = createRootRoute({
loader: () => readShell(),
head: ({ loaderData }) => {
const head = loaderData
? brandHead(brandMetadata(loaderData.brand), loaderData.publicUrl)
: { meta: [{ charSet: "utf-8" }], links: [] }
return {
meta: head.meta,
links: [...head.links, { rel: "stylesheet", href: appCss }, { rel: "stylesheet", href: moduleCss }],
}
},
shellComponent: Shell,
component: Outlet,
notFoundComponent: StatusPage,
errorComponent: RootError,
})
/**
* The organization theme for this request. The document keeps a stable tree for success and
* not-found hydration; CSS delegates visible controls to a rendered AppFrame.
*/
function useOrganizationPage(): Pick<DocumentProps, "tenant"> {
const organization = useMatch({ from: "/$org", shouldThrow: false })
const data = organization?.loaderData
if (!data) return {}
return {
tenant: { style: data.style, ...(data.brandMark ? { mark: data.brandMark } : {}) },
}
}
function Shell({ children }: { readonly children: ReactNode }): ReactElement {
useHydratedSignal()
const shell = useShell()
const page = useOrganizationPage()
if (!shell) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}
const { labels } = shell
return (
<Document
brand={shell.organizationTheme ? { ...shell.brand, style: brandTokensOnly(shell.brand.style) } : shell.brand}
locale={shell.locale}
theme={shell.theme}
signedIn={shell.signedIn}
{...(shell.csrf ? { csrf: shell.csrf } : {})}
{...(shell.observe ? { observe: shell.observe } : {})}
{...(shell.nonce ? { nonce: shell.nonce } : {})}
signIn={labels.signIn}
privacy={shell.privacy}
{...page}
head={<HeadContent />}
scripts={<Scripts />}
signOut={
<>
<a href={shell.accountHref}>{labels.account}</a>
<SignOut label={labels.signOut} />
</>
}
controls={
<>
<LanguageSwitch
current={shell.locale}
signedIn={shell.signedIn}
label={labels.language}
options={[
{ value: "en", label: labels.languageEn },
{ value: "lt", label: labels.languageLt },
]}
failedTemplate={labels.failed}
/>
<ThemeSwitch
initial={shell.theme}
label={labels.theme}
options={[
{ value: "system", label: labels.themeSystem },
{ value: "dark", label: labels.themeDark },
{ value: "light", label: labels.themeLight },
]}
/>
</>
}
>
{shell.organizationTheme ? (
<style
data-org-theme="platform"
{...(shell.nonce ? { nonce: shell.nonce } : {})}
dangerouslySetInnerHTML={{ __html: shell.organizationTheme }}
/>
) : null}
<UiLabelsProvider labels={shell.ui}>
<BoundaryMessages strings={shell.boundary}>{children}</BoundaryMessages>
</UiLabelsProvider>
</Document>
)
}
/** The not-found page, and the forbidden page when guard.ts asked for it (`data.forbidden`). */
export function StatusPage({ data }: NotFoundRouteProps): ReactElement {
const shell = useShell()
const forbidden = (data as StatusPageData | undefined)?.forbidden === true
const labels = shell?.labels
const title = forbidden ? labels?.forbiddenTitle : labels?.notFoundTitle
const body = forbidden ? labels?.forbiddenBody : labels?.notFoundBody
return (
<StateView
title={title ?? (forbidden ? "Forbidden" : "Not found")}
actions={
<Button asChild variant="outline">
<a href="/">{shell?.boundary.home ?? "/"}</a>
</Button>
}
>
<p>{body ?? ""}</p>
</StateView>
)
}
function RootError({ error }: ErrorComponentProps): ReactElement {
const shell = useShell()
return (
<RootErrorView
error={error as Error & { digest?: string }}
// A failed module import remains cached by the browser. A new document
// retries its code and loaders together, under the current session.
retry={() => window.location.reload()}
{...(shell ? { strings: shell.boundary, locale: shell.locale } : {})}
/>
)
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Every HTTP entry point under /api: /api/health, /api/ready, /api/openapi.json, /api/v1/<procedure>, the
// live stream and MCP -- one file, because there is one mount (spec §6.2, `@conusai-kit/host/http`) -- and
// the Better Auth adapter's /api/auth/* when the deployment signs in through it.
//
// A more specific route file (`api.brand.og.ts`) answers its own path first. The handlers are imported on
// the first request, so the browser's bundle never sees the mount.
import { createFileRoute } from "@tanstack/react-router"
async function serve({ request }: { readonly request: Request }): Promise<Response> {
// kit:convex-better-auth:begin
if (new URL(request.url).pathname.startsWith("/api/auth/")) {
const [{ loadAuthConfig }, { betterAuthRuntime }] = await Promise.all([
import("@conusai-kit/auth"),
import("../server/app"),
])
if (loadAuthConfig().provider !== "convex-better-auth") return new Response(null, { status: 404 })
return betterAuthRuntime().proxy(request)
}
// kit:convex-better-auth:end
const { handle } = await import("../server/router")
return handle(request)
}
export const Route = createFileRoute("/api/$")({
server: {
handlers: {
GET: serve,
POST: serve,
// A procedure whose payload is a byte stream (kit-core .stream(field)) is answered on PUT.
PUT: serve,
DELETE: serve,
HEAD: serve,
OPTIONS: serve,
},
},
})
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// This app's brand for its managed identity service's sign-in pages (src/server/brand-routes.ts): public, because
// the service reads it with no session. A more specific file than `api.$.ts`, so it answers its own path first.
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/api/brand/identity")({
server: {
handlers: {
GET: async () => (await import("../server/brand-routes")).identityBrand(),
},
},
})
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The deployment's organization's logo from Cloud (src/server/brand-routes.ts): public, because the sign-in shows it.
// A more specific file than `api.$.ts`, so it answers its own path first.
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/api/brand/logo")({
server: {
handlers: {
GET: async () => (await import("../server/brand-routes")).organizationLogoImage(),
},
},
})
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The app's own share image (src/server/brand-routes.ts): public, because a link preview is fetched without
// a session. A more specific file than `api.$.ts`, so it answers its own path first.
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/api/brand/og")({
server: {
handlers: {
GET: async () => (await import("../server/brand-routes")).appShareImage(),
},
},
})
+33
View File
@@ -0,0 +1,33 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The sign-in routes the host answers: /auth/sign-in/start (to the provider), /auth/callback (the code is
// exchanged on the server and one sealed cookie set), /auth/sign-out and, in development only,
// /auth/dev-login. A failure of any step ends at the sign-in page with a code to read, never at a 500
// (`createHostSignIn`). The pages under /auth (`auth.sign-in.tsx`, `auth.sign-up.tsx`,
// `auth.signed-out.tsx`) are more specific route files and answer their own paths first.
import { createFileRoute } from "@tanstack/react-router"
async function serve({ request }: { readonly request: Request }): Promise<Response> {
const [{ authRoute }, { requestContext }] = await Promise.all([import("../server/app"), import("../server/request")])
return authRoute(request, requestContext)
}
async function signOut(request: Request): Promise<Response> {
const site = request.headers.get("sec-fetch-site")
if (site === null || site === "same-origin" || site === "none") {
const { betterAuthSignOut } = await import("../server/better-auth-session") // kit:convex-better-auth
const betterAuth = await betterAuthSignOut(request) // kit:convex-better-auth
if (betterAuth) return betterAuth // kit:convex-better-auth
}
return serve({ request })
}
export const Route = createFileRoute("/auth/$")({
server: {
handlers: {
GET: serve,
POST: ({ request }) =>
new URL(request.url).pathname === "/auth/sign-out" ? signOut(request) : serve({ request }),
},
},
})
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The sign-in page (spec §7): where a visitor lands when they need to be signed in, and where a
// failed sign-in ends (`createHostSignIn`). It says what went wrong in the person's language -- from a
// fixed set of codes, never from the provider's text -- and offers the way in the deployment has: its
// identity provider, or, in development with none, any e-mail address.
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { SignInData } from "../server/account-pages"
import { SignInCard } from "../ui/sign-in-card"
import { signInSearch } from "../ui/sign-in-search"
const ROUTE = "/auth/sign-in"
const readSignIn = createServerFn({ method: "GET" })
.validator(signInSearch)
.handler(async ({ data }): Promise<SignInData> => {
const [{ reported }, { signInData }] = await Promise.all([
import("../server/page"),
import("../server/account-pages"),
])
return reported(ROUTE, () => signInData(data))
})
export const Route = createFileRoute("/auth/sign-in")({
validateSearch: signInSearch,
loaderDeps: ({ search }) => signInSearch(search),
loader: ({ deps }) => readSignIn({ data: deps }),
component: SignIn,
})
function SignIn(): ReactElement {
return <SignInCard data={Route.useLoaderData()} />
}
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The sign-up page of the Convex Better Auth adapter (AUTH_PROVIDER=convex-better-auth): a new
// account with an e-mail address and a password. An OIDC deployment has its identity provider's own
// registration, so this page is not found there.
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { SignUpData } from "../server/account-pages"
import { BetterAuthForm } from "../ui/better-auth-form"
import { signInSearch } from "../ui/sign-in-search"
import { StateView } from "../ui/state-view"
const ROUTE = "/auth/sign-up"
const readSignUp = createServerFn({ method: "GET" })
.validator(signInSearch)
.handler(async ({ data }): Promise<SignUpData> => {
const [{ reported }, { signUpData }] = await Promise.all([
import("../server/page"),
import("../server/account-pages"),
])
return reported(ROUTE, () => signUpData(data))
})
export const Route = createFileRoute("/auth/sign-up")({
validateSearch: signInSearch,
loaderDeps: ({ search }) => signInSearch(search),
loader: ({ deps }) => readSignUp({ data: deps }),
component: SignUp,
})
function SignUp(): ReactElement {
const data = Route.useLoaderData()
return (
<StateView title={data.title}>
<p>{data.intro}</p>
<BetterAuthForm
mode="sign-up"
returnTo={data.returnTo}
minPasswordLength={data.minPasswordLength}
labels={data.labels}
/>
<p>
<a href={data.toSignIn.href}>{data.toSignIn.label}</a>
</p>
</StateView>
)
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Where the identity provider sends the browser after an RP-initiated sign-out (MIB I3's path).
import { Button } from "@conusai-kit/ui/primitives"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { SignedOutData } from "../server/account-pages"
import { AuthScreen } from "../ui/auth-screen"
const ROUTE = "/auth/signed-out"
const readSignedOut = createServerFn({ method: "GET" }).handler(async (): Promise<SignedOutData> => {
const [{ reported }, { signedOutData }] = await Promise.all([
import("../server/page"),
import("../server/account-pages"),
])
return reported(ROUTE, () => signedOutData())
})
export const Route = createFileRoute("/auth/signed-out")({
loader: () => readSignedOut(),
component: SignedOut,
})
function SignedOut(): ReactElement {
const data = Route.useLoaderData()
return (
<AuthScreen title={data.title} description={data.body}>
<Button asChild>
<a href={data.href}>{data.signIn}</a>
</Button>
</AuthScreen>
)
}
+97
View File
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// kit:custom-ui the organization chooser shares onboarding's centered card and compact account controls
// The entry page. A visitor gets the way in, on the brand's picture; a person with one organization goes to it and
// one with none to onboarding, so only somebody with several organizations sees this list (spec §7).
import { AuthCard, AuthPage, PageSection, PageState } from "@conusai-kit/ui/patterns"
import { Button } from "@conusai-kit/ui/primitives"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { HomeData } from "../server/account-pages"
import { AuthMark } from "../ui/auth-screen"
import { LanguageSwitch, ThemeSwitch } from "../ui/shell-controls"
import { SignInCard } from "../ui/sign-in-card"
import { SignOut } from "../ui/sign-out"
import { useShell } from "../ui/use-shell"
const ROUTE = "/"
const readHome = createServerFn({ method: "GET" }).handler(async (): Promise<HomeData> => {
const [{ reported }, { homeData }] = await Promise.all([import("../server/page"), import("../server/account-pages")])
return reported(ROUTE, () => homeData())
})
export const Route = createFileRoute("/")({
loader: () => readHome(),
component: Home,
})
function Home(): ReactElement {
const data = Route.useLoaderData()
const shell = useShell()
if (!data.signedIn) return <SignInCard data={data.signIn} />
if (!shell) return <AuthPage />
const { labels } = shell
return (
<AuthPage>
<AuthCard
variant="plain"
name={shell.brand.name}
logo={<AuthMark {...shell.brand.logo} />}
title={data.title}
description={data.organizations.length ? data.description : undefined}
footer={
<div className="app-chooser-footer">
<div className="app-chooser-preferences">
<LanguageSwitch
current={shell.locale}
signedIn
label={labels.language}
options={[
{ value: "en", label: labels.languageEn },
{ value: "lt", label: labels.languageLt },
]}
failedTemplate={labels.failed}
/>
<ThemeSwitch
initial={shell.theme}
label={labels.theme}
options={[
{ value: "system", label: labels.themeSystem },
{ value: "dark", label: labels.themeDark },
{ value: "light", label: labels.themeLight },
]}
/>
</div>
<div className="app-chooser-account">
<a href={shell.accountHref}>{labels.account}</a>
<SignOut label={labels.signOut} />
</div>
</div>
}
>
<div className="app-chooser-list">
{data.organizations.length === 0 ? (
<PageState title={data.empty} {...(data.emptyHint ? { description: data.emptyHint } : {})} />
) : (
data.organizations.map((organization) => (
<PageSection
key={organization.slug}
data-guide="home.organization"
variant="card"
title={<a href={`/${organization.slug}`}>{organization.name}</a>}
description={organization.role}
/>
))
)}
</div>
{data.create ? (
<Button asChild variant="outline">
<a href="/onboarding">{data.create}</a>
</Button>
) : null}
</AuthCard>
</AuthPage>
)
}
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The page an invitation link leads to: /invitations/<organization>/<token> (spec §3.1), on the way-in layout
// of the first page. It reads the invitation (`tenancy.invitation.read`, which needs no account) and says what
// it is: who it is for, the organization, the role and until when, or plainly that it was used, revoked or has
// expired. Opening it changes nothing. A visitor chooses "Create your account" or "Sign in": a form POST to
// /auth/invitation (kit-host), which starts the identity service's sign-up with the invited address and lands
// the person in the organization's workspace. A signed-in person with the invited address accepts here.
import { Alert, AlertDescription, Button, Input } from "@conusai-kit/ui/primitives"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { InvitationData } from "../server/account-pages"
import { AuthScreen } from "../ui/auth-screen"
import { InviteAccept } from "../ui/invite-accept"
import { SignOut } from "../ui/sign-out"
const ROUTE = "/invitations/$org/$token"
const readInvitation = createServerFn({ method: "GET" })
.validator((params: { readonly org: string; readonly token: string }) => params)
.handler(async ({ data }): Promise<InvitationData> => {
const [{ reported }, { invitationData }] = await Promise.all([
import("../server/page"),
import("../server/account-pages"),
])
return reported(ROUTE, () => invitationData(data))
})
export const Route = createFileRoute("/invitations/$org/$token")({
loader: ({ params }) => readInvitation({ data: { org: params.org, token: params.token } }),
// The token is the credential: it must not leave in a Referer header, and the page is not for search.
head: () => ({
meta: [
{ name: "referrer", content: "no-referrer" },
{ name: "robots", content: "noindex, nofollow" },
],
}),
component: Invitation,
})
/** One way in: a plain form POST, so it works before the page's script has loaded. */
function StartForm({
start,
intent,
label,
primary,
}: {
start: NonNullable<InvitationData["start"]>
intent: "sign-up" | "sign-in"
label: string
primary?: boolean
}): ReactElement {
return (
<form method="post" action="/auth/invitation">
<Input type="hidden" name="org" value={start.org} />
<Input type="hidden" name="token" value={start.token} />
<Input type="hidden" name="intent" value={intent} />
<Input type="hidden" name="ui_locales" value={start.locale} />
{start.theme ? <Input type="hidden" name="ui_theme" value={start.theme} /> : null}
<Button type="submit" variant={primary ? "default" : "link"}>
{label}
</Button>
</form>
)
}
function Invitation(): ReactElement {
const { org, token } = Route.useParams()
const data = Route.useLoaderData()
return (
<AuthScreen
title={data.title}
footer={data.start ? <StartForm start={data.start} intent="sign-in" label={data.start.signIn} /> : undefined}
>
{data.state === "open" ? (
<>
<p>{data.lead}</p>
{data.expires ? <p>{data.expires}</p> : null}
</>
) : (
<Alert role="alert">
<AlertDescription>{data.lead}</AlertDescription>
</Alert>
)}
{data.start ? <StartForm start={data.start} intent="sign-up" label={data.start.createAccount} primary /> : null}
{data.signInFirst ? (
<>
<p>{data.signInFirst.first}</p>
<Button asChild>
<a href={data.signInFirst.href}>{data.signInFirst.label}</a>
</Button>
</>
) : null}
{data.signedIn ? (
<>
<p>{data.signedIn.as}</p>
{data.signedIn.otherAccount ? <p>{data.signedIn.otherAccount}</p> : null}
{data.signedIn.labels ? <InviteAccept org={org} token={token} labels={data.signedIn.labels} /> : null}
<SignOut label={data.signedIn.switchAccount} />
</>
) : null}
</AuthScreen>
)
}
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// `/members`: the one address of "the members page" that holds for every person, which the platform
// console's identity card links to (ADR-062: the console says who may enter the app, the app's own
// pages say what they may do in it). It leads to the members page of the person's organization:
//
// signed out to the sign-in page, which brings them back here
// no organization to the entry page, which says so (and offers to create one where allowed)
// one organization to `/<org>/members`, whose route decides what they may see there
// several the organizations, each linking to its members page
//
// It reads `tenancy.me.get`, the person's own organizations, and nothing of any other.
import { Page, PageHeader } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { MembersEntryData } from "../server/account-pages"
const ROUTE = "/members"
const readMembers = createServerFn({ method: "GET" }).handler(async (): Promise<MembersEntryData> => {
const [{ reported }, { membersEntryData }] = await Promise.all([
import("../server/page"),
import("../server/account-pages"),
])
return reported(ROUTE, () => membersEntryData())
})
export const Route = createFileRoute("/members")({
loader: () => readMembers(),
component: Members,
})
function Members(): ReactElement {
const data = Route.useLoaderData()
return (
<main>
<Page>
<PageHeader title={data.title} />
<ul>
{data.organizations.map((organization) => (
<li key={organization.slug}>
<a href={`/${encodeURIComponent(organization.slug)}/members`}>{organization.name}</a>
</li>
))}
</ul>
</Page>
</main>
)
}
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Onboarding: one card, the first organization, which creates its default workspace in one transaction
// (kit-tenancy's use case), with the signed-in user as its owner, and lands in that workspace. Joining an
// existing organization is not done by name (spec §3.3): it is an invitation, which has its own page.
//
// A deployment that keeps founding an organization to its operators (APP_ORGANIZATION_SELF_SERVICE=false)
// shows this page only to a person the identity link grants the app-access role `admin`; anyone else
// gets the forbidden page, and `tenancy.org.create` refuses them whatever page they call it from. So does a
// one-organization deployment (APP_SINGLE_ORGANIZATION) once its organization exists.
import { AuthCard, AuthPage } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { OnboardingData } from "../server/account-pages"
import { OnboardingForm } from "../ui/onboarding-form"
const ROUTE = "/onboarding"
const readOnboarding = createServerFn({ method: "GET" }).handler(async (): Promise<OnboardingData> => {
const [{ reported }, { onboardingData }] = await Promise.all([
import("../server/page"),
import("../server/account-pages"),
])
return reported(ROUTE, () => onboardingData())
})
export const Route = createFileRoute("/onboarding")({
loader: () => readOnboarding(),
component: Onboarding,
})
function Onboarding(): ReactElement {
const data = Route.useLoaderData()
return (
<AuthPage>
<AuthCard variant="plain" name={data.name} title={data.title} description={data.description}>
<OnboardingForm labels={data.labels} workspace={data.workspace} />
</AuthCard>
</AuthPage>
)
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The person's own account (joint contract §12): what the app keeps about them, the export of it, and
// the deletion of the account. The page is a user page, under `settings`, which is a reserved
// organization slug, so no organization's path can shadow it.
//
// The export and the deletion are the account module's procedures; this page only renders them, and
// the client host beside it ends the browser modules' context once a deletion answered. The analytics
// panel (ADR-049) erases or exports what the platform's own stores keep about the person's visits,
// through the account.analytics procedures: the deployment token stays on the server.
import { FactList, Page, PageHeader, PageSection } from "@conusai-kit/ui/patterns"
import { createFileRoute } from "@tanstack/react-router"
import { createServerFn } from "@tanstack/react-start"
import type { ReactElement } from "react"
import type { AccountData } from "../server/account-pages"
import { AccountPanels } from "../ui/account"
const ROUTE = "/settings/account"
const readAccount = createServerFn({ method: "GET" }).handler(async (): Promise<AccountData> => {
const [{ reported }, { accountData }] = await Promise.all([
import("../server/page"),
import("../server/account-pages"),
])
return reported(ROUTE, () => accountData(true))
})
export const Route = createFileRoute("/settings/account")({
loader: () => readAccount(),
component: Account,
})
function Account(): ReactElement {
const { page, displayName, email, exportLabels, analyticsLabels, deletion } = Route.useLoaderData()
return (
<main>
<Page>
<PageHeader title={page.title} description={page.intro} />
<PageSection title={page.profileHeading} variant="card">
<FactList
facts={[
{ label: page.name, value: displayName },
{ label: page.email, value: email },
]}
/>
</PageSection>
<AccountPanels exportLabels={exportLabels} analyticsLabels={analyticsLabels} deletion={deletion} />
</Page>
</main>
)
}
+90
View File
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The server entry: what Next's `proxy.ts` and `instrumentation.ts` did, on TanStack Start (F10).
//
// - One nonce per request, handed to the renderer (src/router.tsx reads `x-nonce`) and named by the
// content security policy, with the security headers of spec §6.4 on every response.
// - `x-path`: where this request is, for a loader that sends a visitor to sign in and back.
// - A page status a loader asked for (guard.ts's forbidden page) is the response's status.
// - The deployment contract is parsed as the process starts: a missing or forbidden variable is logged by
// name, `/api/ready` says what to set, and every route answers 503 (server/contract.ts).
// - An error that escapes the handler is reported (report.ts); Start has no `onRequestError`.
//
// A request whose body is a byte stream (`PUT /api/v1/<procedure>` of a `.stream(field)` procedure) passes
// through untouched: nothing here reads a body.
import { securityHeaders } from "@conusai-kit/host/headers"
import handler, { createServerEntry } from "@tanstack/react-start/server-entry"
import { type ContractRefusal, issuerOrigins, refusalOf, refusalResponse } from "./server/contract"
import { PAGE_STATUS_HEADER } from "./server/guard"
function logContractRefusal(error: unknown): ContractRefusal {
const refusal = refusalOf(error)
console.error(`web: refused to serve: ${refusal.message}`)
return refusal
}
let contractChecked: Promise<ContractRefusal | null> | undefined
const checkContract = (): Promise<ContractRefusal | null> => {
contractChecked ??= Promise.all([import("@app/config"), import("@conusai-kit/auth")]).then(
async ([config, { loadAuthConfig }]) => {
try {
config.loadAppEnv("web")
loadAuthConfig()
} catch (error: unknown) {
return logContractRefusal(error)
}
// kit:profile-dialect:begin
// Validate adopted module custody before lazy runtime/store construction. Only the
// known composition refusal is configuration; registry/import bugs still throw.
if (config.PROFILE === "mariadb" || config.PROFILE === "mongodb") {
const { appModules } = await import("@app/modules")
try {
config.profileRegistry(appModules, config.PROFILE)
} catch (error: unknown) {
if (!(error instanceof config.ProfileCompositionError)) throw error
return logContractRefusal(error)
}
}
// kit:profile-dialect:end
return null
},
)
return contractChecked
}
// At start, not at the first request: the log says what is wrong before anything probes this process.
void checkContract()
export default createServerEntry({
async fetch(request, options) {
const refused = await checkContract()
if (refused) return refusalResponse(request, refused)
const url = new URL(request.url)
const nonce = Buffer.from(globalThis.crypto.randomUUID()).toString("base64")
const development = process.env.NODE_ENV === "development"
const secure = url.protocol === "https:" || request.headers.get("x-forwarded-proto") === "https"
request.headers.set("x-nonce", nonce)
request.headers.set("x-path", `${url.pathname}${url.search}`)
let response: Response
try {
response = await handler.fetch(request, { ...options, context: { ...options?.context, nonce } })
} catch (error) {
const { reportFailure } = await import("./server/report")
reportFailure(error, request, { routePath: url.pathname, routeType: "handler" })
throw error
}
const asked = response.headers.get(PAGE_STATUS_HEADER)
if (asked) {
const headers = new Headers(response.headers)
headers.delete(PAGE_STATUS_HEADER)
response = new Response(response.body, { status: Number(asked), statusText: "", headers })
}
const formActions = issuerOrigins(process.env.OIDC_ISSUER)
for (const [name, value] of Object.entries(securityHeaders({ nonce, secure, development, formActions }))) {
response.headers.set(name, value)
}
return response
},
})
+454
View File
@@ -0,0 +1,454 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The loaders of the pages outside an organization: the entry page, onboarding, the person's account, the
// sign-in pages, an invitation and `/members` (F10). Each returns plain data with its labels already
// translated; `src/routes/*` render it. Server-only: a route reaches this file through a server function
// whose handler imports it.
import { appBrand } from "@app/config"
import { ACCOUNT_PATH, accountDeletionStatus } from "@app/modules/account/contract"
import {
type AccountAnalyticsLabels,
type AccountDeletionProps,
type AccountExportLabels,
analyticsLabels,
deletionLabels,
exportLabels,
pageLabels,
} from "@app/modules/account/ui"
import { loadAuthConfig } from "@conusai-kit/auth"
import { AppError } from "@conusai-kit/core"
import { invitationRead, meGet } from "@conusai-kit/tenancy"
import { builtInRoleName } from "@conusai-kit/tenancy/ui"
import { getRequest } from "@tanstack/react-start/server"
import type { BetterAuthFormProps } from "../ui/better-auth-form" // kit:convex-better-auth
import type { InviteAcceptLabels } from "../ui/invite-accept"
import type { OnboardingLabels } from "../ui/onboarding-form"
import { WORKSPACE_COOKIE } from "../ui/preference-cookies"
import type { SignInSearch } from "../ui/sign-in-search"
import { AUTH_ERROR_MESSAGES, parseAuthError, safeReturnTo } from "./auth-errors"
import { betterAuthFormLabels, MIN_PASSWORD_LENGTH } from "./better-auth" // kit:convex-better-auth
import * as guard from "./guard" // kit:convex-better-auth
import { orFallback, pageForbidden, signInHref, toPage, toSignIn } from "./guard"
import { pageContext } from "./page"
import { cookieValue } from "./preferences"
// ---------------------------------------------------------------- the entry page
export type HomeData =
| { readonly signedIn: false; readonly signIn: SignInData }
| {
readonly signedIn: true
readonly title: string
readonly description: string
readonly empty: string
/** What a person with no organization, who may not found one, can do. */
readonly emptyHint?: string
readonly organizations: readonly { readonly slug: string; readonly name: string; readonly role: string }[]
/** The link to found one, for a person who may. */
readonly create?: string
}
export async function homeData(): Promise<HomeData> {
const { caller, signedIn, i18n, path, mayCreateOrganization } = await pageContext()
if (!signedIn) return { signedIn: false, signIn: await signInData({}) }
// The typed in-process client: the contract decides the input and the output types, and the
// same procedure answers a browser, a CLI and a partner (spec §6.2).
const me = await orFallback(path, () => caller.call(meGet, undefined))
const [only] = me.organizations
if (only && me.organizations.length === 1) toPage(`/${encodeURIComponent(only.slug)}`)
if (!only && mayCreateOrganization) toPage("/onboarding")
return {
signedIn: true,
title: i18n.message("app.organizations"),
description: i18n.message("app.chooseOrganization"),
empty: i18n.message("app.noOrganizations"),
...(only ? {} : { emptyHint: i18n.message("app.askForInvitation") }),
organizations: me.organizations.map((organization) => ({
slug: organization.slug,
name: organization.name,
role: builtInRoleName(i18n, organization.role),
})),
...(mayCreateOrganization ? { create: i18n.message("app.createOrganization") } : {}),
}
}
// ---------------------------------------------------------------- onboarding
export interface OnboardingData {
readonly name: string
readonly title: string
readonly description: string
readonly labels: OnboardingLabels
/** The first workspace, in the deployment's language: it belongs to everybody who joins later. */
readonly workspace: { readonly name: string; readonly slug: string }
}
export async function onboardingData(): Promise<OnboardingData> {
const { i18n, deploymentI18n, signedIn, path, mayCreateOrganization } = await pageContext()
if (!signedIn) toSignIn(path)
if (!mayCreateOrganization) pageForbidden()
return {
name: appBrand.name,
title: i18n.message("onboarding.title"),
description: i18n.message("onboarding.description"),
labels: {
name: i18n.message("onboarding.name"),
nameHelp: i18n.message("onboarding.nameHelp"),
address: i18n.message("onboarding.address"),
addressHelp: i18n.message("onboarding.addressHelp"),
addressPrefix: "app/",
continue: i18n.message("onboarding.continue"),
required: i18n.message("onboarding.required"),
addressInvalid: i18n.message("onboarding.addressInvalid"),
addressTaken: i18n.message("onboarding.addressTaken"),
failed: i18n.message("onboarding.failed"),
},
workspace: {
name: deploymentI18n.message("onboarding.workspaceName"),
slug: deploymentI18n.message("onboarding.workspaceSlug"),
},
}
}
// ---------------------------------------------------------------- /members
export interface MembersEntryData {
readonly title: string
readonly organizations: readonly { readonly slug: string; readonly name: string }[]
}
export async function membersEntryData(): Promise<MembersEntryData> {
const { caller, signedIn, i18n, path } = await pageContext()
if (!signedIn) toSignIn(path)
const { organizations } = await orFallback(path, () => caller.call(meGet, undefined))
const [only] = organizations
if (organizations.length === 0) toPage("/")
if (only && organizations.length === 1) toPage(`/${encodeURIComponent(only.slug)}/members`)
return {
title: i18n.message("app.organizations"),
organizations: organizations.map(({ slug, name }) => ({ slug, name })),
}
}
// ---------------------------------------------------------------- the person's account
export interface AccountData {
readonly page: ReturnType<typeof pageLabels>
readonly displayName: string
readonly email: string
readonly exportLabels: AccountExportLabels
readonly analyticsLabels: AccountAnalyticsLabels
readonly deletion: Omit<AccountDeletionProps, "onDeleted">
}
/**
* Where a person signs in again to delete their account: straight to the provider (the sign-in page
* sends somebody already signed in away), or, in development, the dev login with their own address.
* Either opens a new session, whose sign-in is then recent.
*/
function reauthHref(email: string): string {
const back = `return_to=${encodeURIComponent(ACCOUNT_PATH)}`
const config = loadAuthConfig()
if (config.oidc) return `/auth/sign-in/start?${back}`
if (config.devLogin) return `/auth/dev-login?email=${encodeURIComponent(email)}&${back}`
return `/auth/sign-in?${back}`
}
export async function accountData(preferOrganization = false): Promise<AccountData> {
const { caller, i18n, signedIn, path } = await pageContext()
if (!signedIn) toSignIn(path)
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`account.${key}`, values)
const [me, status] = await orFallback(path, () =>
Promise.all([caller.call(meGet, undefined), caller.call(accountDeletionStatus, undefined)]),
)
if (preferOrganization) {
const remembered = cookieValue(getRequest().headers.get("cookie"), WORKSPACE_COOKIE)?.split("/")
const selected =
remembered?.length === 2 && remembered[1]
? me.organizations.find((organization) => organization.slug === remembered[0])
: undefined
const organization = selected ?? (me.organizations.length === 1 ? me.organizations[0] : undefined)
if (organization) toPage(`/${encodeURIComponent(organization.slug)}/settings/account`)
}
const failed = i18n.message("app.failed")
return {
page: pageLabels(t),
displayName: me.displayName,
email: me.email,
exportLabels: exportLabels(t, failed),
analyticsLabels: analyticsLabels(t, failed),
deletion: {
labels: deletionLabels(t, status, me.email),
interactive: status.interactive,
recentSignIn: status.recentSignIn,
reauthHref: reauthHref(me.email),
deletion: status.deletion,
},
}
}
// ---------------------------------------------------------------- signing in
export interface SignInData {
readonly title: string
/** What went wrong, from a fixed set of codes. */
readonly error?: string
/** The deployment's identity provider. */
readonly provider?: { readonly href: string; readonly label: string }
/** The provider's registration, for a person with no account. */
readonly signUp?: { readonly href: string; readonly label: string }
/** In development with no provider: any e-mail address. */
readonly dev?: {
readonly title: string
readonly help: string
readonly email: string
readonly submit: string
readonly returnTo?: string
/** The address to start with: the one an invitation was sent to. */
readonly address?: string
}
// kit:convex-better-auth:begin
/** The Convex Better Auth adapter's form. */
readonly betterAuth?: {
readonly returnTo: string
readonly minPasswordLength: number
readonly labels: BetterAuthFormProps["labels"]
readonly toSignUp: { readonly href: string; readonly label: string }
}
// kit:convex-better-auth:end
readonly notConfigured?: string
}
export async function signInData({
error,
return_to: returnToParam,
login_hint: loginHint,
}: SignInSearch): Promise<SignInData> {
const { i18n, signedIn, theme } = await pageContext()
const returnTo = safeReturnTo(returnToParam)
// Somebody who is already in has nothing to do here, unless they were sent back with an error.
const code = parseAuthError(error)
if (signedIn && !code) toPage(returnTo)
const config = loadAuthConfig()
const next = returnTo === "/" ? "" : `?return_to=${encodeURIComponent(returnTo)}` // kit:convex-better-auth
// The page's language and theme go along, so the identity provider's form opens the way this one looks.
const start = (intent?: "sign-up"): string => {
const query = new URLSearchParams({ ui_locales: i18n.locale })
if (theme === "light" || theme === "dark") query.set("ui_theme", theme)
if (returnTo !== "/") query.set("return_to", returnTo)
if (intent) query.set("intent", intent)
return `/auth/sign-in/start?${query}`
}
return {
title: i18n.message("signin.title"),
...(code ? { error: i18n.message(AUTH_ERROR_MESSAGES[code]) } : {}),
...(config.oidc
? {
provider: { href: start(), label: i18n.message("app.signIn") },
signUp: { href: start("sign-up"), label: i18n.message("signin.createAccount") },
}
: {}),
...(!config.oidc && config.devLogin
? {
dev: {
title: i18n.message("signin.devTitle"),
help: i18n.message("signin.devHelp"),
email: i18n.message("app.email"),
submit: i18n.message("signin.devSubmit"),
...(returnTo === "/" ? {} : { returnTo }),
...(loginHint ? { address: loginHint } : {}),
},
}
: {}),
// kit:convex-better-auth:begin
...(config.provider === "convex-better-auth"
? {
betterAuth: {
returnTo,
minPasswordLength: MIN_PASSWORD_LENGTH,
labels: betterAuthFormLabels(i18n, "sign-in"),
toSignUp: { href: `/auth/sign-up${next}`, label: i18n.message("signin.account.toSignUp") },
},
}
: {}),
// kit:convex-better-auth:end
...(!config.oidc && !config.devLogin && config.provider === "oidc"
? { notConfigured: i18n.message("signin.notConfigured") }
: {}),
}
}
// kit:convex-better-auth:begin
export interface SignUpData {
readonly title: string
readonly intro: string
readonly returnTo: string
readonly minPasswordLength: number
readonly labels: BetterAuthFormProps["labels"]
readonly toSignIn: { readonly href: string; readonly label: string }
}
/** The Convex Better Auth adapter's own page: an OIDC deployment has its provider's registration. */
export async function signUpData({ return_to: returnToParam }: SignInSearch): Promise<SignUpData> {
if (loadAuthConfig().provider !== "convex-better-auth") guard.pageNotFound()
const { i18n, signedIn } = await pageContext()
const returnTo = safeReturnTo(returnToParam)
if (signedIn) toPage(returnTo)
const next = returnTo === "/" ? "" : `?return_to=${encodeURIComponent(returnTo)}`
return {
title: i18n.message("signin.account.signUpTitle"),
intro: i18n.message("signin.account.signUpIntro"),
returnTo,
minPasswordLength: MIN_PASSWORD_LENGTH,
labels: betterAuthFormLabels(i18n, "sign-up"),
toSignIn: { href: `/auth/sign-in${next}`, label: i18n.message("signin.account.toSignIn") },
}
}
// kit:convex-better-auth:end
export interface SignedOutData {
readonly title: string
readonly body: string
readonly signIn: string
readonly href: string
}
export async function signedOutData(): Promise<SignedOutData> {
const { i18n } = await pageContext()
return {
title: i18n.message("app.signedOut"),
body: i18n.message("app.signedOutBody"),
signIn: i18n.message("app.signIn"),
href: "/auth/sign-in",
}
}
// ---------------------------------------------------------------- an invitation
/** The states an invitation link is in, and `invalid` for a link that is not one. */
export type InvitationLinkState = "open" | "used" | "revoked" | "expired" | "invalid"
export interface InvitationData {
readonly title: string
readonly state: InvitationLinkState
/** Who it is for and where it leads, or what is wrong with it, in the person's language. */
readonly lead: string
/** When an open link stops working. */
readonly expires?: string
/**
* A visitor with an open link: "Create your account" and "Sign in" (POST /auth/invitation), the
* page's language and theme going along to the identity service.
*/
readonly start?: {
readonly org: string
readonly token: string
readonly locale: string
readonly theme?: "light" | "dark"
readonly createAccount: string
readonly signIn: string
}
/** A deployment whose sign-in takes no invitation (Convex Better Auth): sign in and come back. */
readonly signInFirst?: { readonly first: string; readonly href: string; readonly label: string }
/** A signed-in person: who they are, and the accept button when the link is for them. */
readonly signedIn?: {
readonly as: string
/** The address the link is for, when it is not the signed-in one. */
readonly otherAccount?: string
readonly labels?: InviteAcceptLabels
readonly switchAccount: string
}
}
/**
* The invitation a link names, as its holder may see it (`tenancy.invitation.read`, which needs no
* account): whether it still works, who it is for, the organization and the role. Opening the page
* changes nothing; the buttons are what start a sign-in or accept.
*/
export async function invitationData({
org,
token,
}: {
readonly org: string
readonly token: string
}): Promise<InvitationData> {
const { i18n, signedIn, user, path, caller, theme } = await pageContext()
const message = (key: string, values?: Record<string, string | number>): string => i18n.message(key, values)
const title = message("invite.title")
let invitation: Awaited<ReturnType<typeof readInvitationLink>> | undefined
try {
invitation = await readInvitationLink(caller, org, token)
} catch (error) {
// Not a link of this organization, or no link at all: one answer for both.
if (!(error instanceof AppError) || error.status !== 404) throw error
}
if (!invitation) return { title, state: "invalid", lead: message("invite.error.unknown") }
if (invitation.state !== "open") {
return { title, state: invitation.state, lead: message(`invite.error.${invitation.state}`) }
}
const role = invitation.roleKey.startsWith("custom:") ? undefined : builtInRoleName(i18n, invitation.role)
const values = { email: invitation.email, org: invitation.organization.name, ...(role ? { role } : {}) }
const lead = message(role ? "invite.lead" : "invite.leadNoRole", values)
const expires = message("invite.expires", {
date: new Intl.DateTimeFormat(i18n.locale, { dateStyle: "long", timeStyle: "short" }).format(
new Date(invitation.expiresAt),
),
})
if (signedIn) {
const mine = user?.email.trim().toLowerCase() === invitation.email
return {
title,
state: "open",
lead,
expires,
signedIn: {
as: message("invite.signedInAs", { email: user?.email ?? "" }),
...(mine
? {
labels: {
accept: message("invite.accept"),
accepted: message("invite.accepted", { org: invitation.organization.name }),
invalid: message("invite.error.invalid"),
used: message("invite.error.used"),
revoked: message("invite.error.revoked"),
expired: message("invite.error.expired"),
member: message("invite.error.member"),
failed: message("invite.error.failed"),
},
}
: { otherAccount: message("invite.otherAccount", { email: invitation.email }) }),
switchAccount: message("invite.switchAccount"),
},
}
}
const config = loadAuthConfig()
if (!config.oidc && !config.devLogin) {
return {
title,
state: "open",
lead,
expires,
signInFirst: { first: message("invite.signInFirst"), href: signInHref(path), label: message("app.signIn") },
}
}
return {
title,
state: "open",
lead,
expires,
start: {
org,
token,
locale: i18n.locale,
...(theme === "light" || theme === "dark" ? { theme } : {}),
createAccount: message("invite.createAccount"),
signIn: message("invite.haveAccount"),
},
}
}
/** `tenancy.invitation.read` through the page's caller: the same procedure the API serves. */
const readInvitationLink = (caller: PageCaller, org: string, token: string) =>
caller.call(invitationRead, { org, token })
type PageCaller = Awaited<ReturnType<typeof pageContext>>["caller"]
+508
View File
@@ -0,0 +1,508 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The web process's composition root (spec §2). On Postgres it is `createPostgresHost`: this app's
// modules, ports and brand, handed to `@conusai-kit/host/postgres`. The Convex, MariaDB and MongoDB
// profiles are composed here, to the same shape, from the same request and sign-in code.
//
// One runtime per process. `vite build` writes one server graph whose chunks share this file, and the kit
// stays external, so every route reaches one copy of the kit and one runtime. A second copy of the kit would
// hand `@conusai-kit/auth` the data source of another ("entity session is not the definition this data source
// was built with"). In development Vite may evaluate this file again after an edit, and that copy builds a
// runtime of its own, which is only a second pool.
import {
type AppEnv,
appBrand,
businessServiceComposition,
identityLinkOf,
liveCaptureMigrations, // kit:profile-dialect
loadAppEnv, // kit:profile-other
organizationSelfServiceOf, // kit:profile-other
type PlatformClients,
PROFILE,
platformClients, // kit:profile-other
profileRegistry, // kit:profile-dialect
type ServerObservability,
startObservability, // kit:profile-other
} from "@app/config"
import { appModules } from "@app/modules"
import { recordSessionRevocation } from "@app/modules/account"
import type {
AppSessionAdapter,
AppSessionAdapterFactories,
AuthRoutes,
IdentityAdmission,
SessionRef,
} from "@conusai-kit/auth"
import { loadAuthConfig } from "@conusai-kit/auth" // kit:convex-better-auth
import {
buildRegistry, // kit:profile-other
type CallerOptions,
Container,
type Registry,
} from "@conusai-kit/core"
import {
DATA_SOURCE, // kit:profile-dialect
type DataSource,
entitiesOf, // kit:profile-other
IDEMPOTENCY, // kit:profile-dialect
JOB_QUEUE, // kit:profile-dialect
OUTBOX, // kit:profile-dialect
SYSTEM_DATA, // kit:profile-dialect
type SystemDataSource,
} from "@conusai-kit/data"
import type { KyselyDataSource } from "@conusai-kit/data-kysely"
import {
createHostSignIn,
createPostgresHost,
type HostSignIn,
invitationCallsOf,
kernelRequestContext,
type PostgresHost,
SESSION_ADAPTERS,
SESSION_REVOKED,
} from "@conusai-kit/host/postgres"
import { KIT_JOBS_ENTITIES } from "@conusai-kit/jobs-pgboss" // kit:profile-dialect
import type { LiveLog } from "@conusai-kit/live-server"
import { GRANTS_CHANGED, GRANTS_CHANGED_FAILED } from "@conusai-kit/tenancy" // kit:profile-other
import { observeRegister } from "@conusai-sdk/observe-start/register" // kit:profile-other
import { ConvexHttpClient } from "convex/browser" // kit:profile-convex
import { makeFunctionReference } from "convex/server" // kit:profile-convex
import type { Pool } from "pg"
import { CONVEX_PROCEDURES } from "../../../../convex/generated/procedures" // kit:profile-convex
import { convexBffModules } from "../../../../convex/lib/convexModules.mjs" // kit:profile-convex
import * as betterAuthAdapter from "./better-auth" // kit:convex-better-auth
import { createConvexAuthDataSource } from "./convex-auth-data" // kit:profile-convex
import { deliverConvexAccountEvent } from "./convex-caller" // kit:convex-better-auth
import { revokeConvexSession } from "./convex-revocation" // kit:profile-convex
import { revokeDerivedSessions } from "./derived-sessions"
import { dialectStores } from "./dialect/stores" // kit:profile-dialect
import { liveGrantsChanged } from "./live-grants" // kit:profile-other
import { bindWebPorts, nativeHelpSelectors } from "./ports"
import type { DialectDataSource, StoreProbes } from "./stores"
export interface WebRuntime extends Pick<CallerOptions, "prepareServices" | "serviceReadiness"> {
readonly env: AppEnv
readonly registry: Registry
/** The data source of the postgres (Kysely), mariadb (Kysely MySQL) or mongodb profile. */
readonly data?: KyselyDataSource | DialectDataSource
/** The two pools, for the readiness probe: everything else reaches them through the data source. */
readonly pools?: { readonly runtime: Pool; readonly reader: Pool }
/** The mariadb and mongodb profiles' readiness probes (one login, so no reader). */
readonly store?: StoreProbes
/** The live log of a dialect profile (mariadb triggers, mongodb change stream), for live.ts. */
readonly liveLog?: () => LiveLog
readonly convex?: NonNullable<AppEnv["convex"]>
readonly authData?: DataSource & SystemDataSource
/** The process-wide container. A request resolves from a child of it (request.ts). */
readonly root: Container
/** The platform's services (AI, OCR, Blob, audit, usage), built once from the deployment contract. */
readonly platform: PlatformClients
/** Errors, traces and logs (config/src/observability.ts). */
readonly observe: ServerObservability
/** Whether any signed-in person may create an organization (APP_ORGANIZATION_SELF_SERVICE, ADR-062). */
readonly selfServiceOrganizations: boolean
/** One deployment, one organization (APP_SINGLE_ORGANIZATION). */
readonly singleOrganization: boolean
/** The identity link invitations admit their address through (OIDC_LINK_ID), or why there is none. */
readonly identityLink: ReturnType<typeof identityLinkOf>
}
let runtime: WebRuntime | undefined
let checked: Promise<void> | undefined
let host: PostgresHost | undefined
let signIn: HostSignIn | undefined
/**
* The request context an invitation link's sign-in calls through: request.ts's, which `/auth/*` hands to
* `authRoute`. request.ts imports this file, so this file never imports it (no cycle between the two).
*/
let invitationContext: Parameters<typeof invitationCallsOf>[0] | undefined
let kernel: ReturnType<typeof kernelRequestContext> | undefined
let betterAuth: betterAuthAdapter.BetterAuthRuntime | undefined // kit:convex-better-auth
let draining: Promise<void> | undefined
let closeDialect: (() => Promise<void>) | undefined
/**
* What SIGTERM and SIGINT, which the platform sends before it replaces or stops the container, end in: flush the
* queued usage events (an unflushed queue is unbilled usage), send what the observability exporters hold, and close
* the pools. `server.mjs` owns the signal and the exit: it stops taking requests, waits for the ones in flight, awaits
* this, and only then exits. So this registers no signal handler of its own, because a handler here would race the
* exit and start closing pools under requests still running. `vite dev` calls nothing: a development server holds no
* usage worth keeping.
*/
function drain(): Promise<void> {
draining ??= (async () => {
runtime?.observe.log.info("web: flushing and closing")
await betterAuth?.stop().catch(() => undefined) // kit:convex-better-auth
if (host) await host.close()
else if (runtime) {
const current = runtime
await current.platform.usage.close().catch((error: unknown) => {
current.observe.log.warn(
`web: the usage queue was not flushed: ${error instanceof Error ? error.message : "failed"}`,
)
})
await current.observe.shutdown().catch(() => undefined)
// A dialect profile closes its queue connection with its store.
await (closeDialect ? closeDialect() : current.data?.close())?.catch(() => undefined)
}
process.stdout.write("web: flushed and closed\n")
})()
return draining
}
/** Where `server.mjs` finds the drain: the build's one bundle is not a module it can import a name from. */
function offerDrain(): void {
;(globalThis as unknown as Record<symbol, () => Promise<void>>)[Symbol.for("conusai.app.drain")] = drain
}
/**
* The process, built on its first request. The server entry (src/server.ts) parses the deployment contract
* first, so a misconfigured deployment stops the server before it takes one: `loadAppEnv` throws a
* typed error naming the variable (a missing platform URL or token, the fake platform in a production
* run, an owner's database URL in the web container).
*/
export function webRuntime(): WebRuntime {
if (runtime) return runtime
if (PROFILE === "postgres") {
const postgres = postgresHost()
runtime = postgres.runtime()
checked = postgres.rolesChecked()
return runtime
}
// kit:profile-other:begin
const env = loadAppEnv("web")
const observe = startObservability(env, { initErrors: observeRegister })
// Parsed at boot with the rest of the contract: a value that is neither true nor false, or a link id
// without its admission credential, stops the server.
const selfServiceOrganizations = organizationSelfServiceOf()
const identityLink = identityLinkOf()
// kit:profile-other:end
// kit:profile-convex:begin
if (PROFILE === "convex") {
if (!env.convex) throw new Error("Convex profile environment was not loaded")
// Keep the supported file effects in the HTTP registry so its routes pass the kernel's
// input/scope/permission checks. createConvexCaller intercepts them and runs the composed
// server action/mutation workflow; the Convex backend registry itself omits effect wrappers.
const registry = buildRegistry([...convexBffModules(appModules)])
const platform = platformClients(env, registry)
const authData = createConvexAuthDataSource(env.convex)
const root = new Container()
bindSignInPorts(root)
bindGrantChanges(root, observe)
const current: WebRuntime = {
env,
registry,
root,
platform,
observe,
convex: env.convex,
authData,
selfServiceOrganizations,
singleOrganization: false,
identityLink,
}
runtime = current
checked = verifyConvexCapabilities(current)
offerDrain()
return current
}
// kit:profile-convex:end
// kit:profile-dialect:begin -- the mariadb and mongodb profiles (dialect/stores.ts)
if (PROFILE === "mariadb" || PROFILE === "mongodb") {
const registry = profileRegistry(appModules, PROFILE)
const platform = platformClients(env, registry)
const stores = dialectStores(
env,
[...entitiesOf(registry, PROFILE), ...KIT_JOBS_ENTITIES],
liveCaptureMigrations(registry, PROFILE),
)
if (!stores) throw new Error(`${PROFILE} profile environment was not loaded`)
const { data } = stores
const root = new Container()
root.value(DATA_SOURCE, data)
root.value(SYSTEM_DATA, data)
root.value(OUTBOX, data.outbox)
root.value(IDEMPOTENCY, data.idempotency)
bindWebPorts(root, platform, registry)
bindSignInPorts(root)
bindGrantChanges(root, observe)
betterAuthAdapter.bindBetterAuthAccountEnd(root) // kit:convex-better-auth
// BullMQ has no transactional enqueue: a job a write asks for is enqueued after it commits,
// and a write that must enqueue atomically publishes an outbox event instead (the relay runs it).
root.register(JOB_QUEUE, () => stores.queue())
const current: WebRuntime = {
...businessServiceComposition(env, appModules, PROFILE, { model: platform.model, ...nativeHelpSelectors }),
env,
registry,
data,
store: stores.probes,
...(stores.liveLog ? { liveLog: stores.liveLog } : {}),
root,
platform,
observe,
selfServiceOrganizations,
singleOrganization: false,
identityLink,
}
runtime = current
// AT-D15: a standalone MongoDB has no transactions, so the process stops rather than serve on it.
checked = stores.probes.boot().catch((error: unknown) => {
observe.log.error(`web: ${error instanceof Error ? error.message : String(error)}`)
process.exit(78)
})
closeDialect = () => stores.close()
offerDrain()
return current
}
// kit:profile-dialect:end
throw new Error(`the ${PROFILE} profile has no web composition`)
}
/** The Postgres profile's host: this app's modules, its ports and its brand. Built once, connected on first use. */
export function postgresHost(): PostgresHost {
// Offered with the host, not with `webRuntime`: a process that has only answered `/api` holds a usage queue too.
if (!host) offerDrain()
host ??= createPostgresHost({
callerServices: (env, platform) =>
businessServiceComposition(env, appModules, "postgres", { model: platform.model, ...nativeHelpSelectors }),
modules: appModules,
brand: appBrand,
bindPorts: (root, platform, registry) => {
// The platform's services, bound to the tokens a module resolves them by (ports.ts).
bindWebPorts(root, platform, registry)
bindSignInPorts(root)
// An account deletion ends the person's Better Auth account too (AUTH_PROVIDER=convex-better-auth).
betterAuthAdapter.bindBetterAuthAccountEnd(root) // kit:convex-better-auth
},
})
return host
}
/** What the host's sign-in asks of this app: the end of what it derived from a session, and its other providers. */
function bindSignInPorts(root: Container): void {
root.value(SESSION_REVOKED, (ref) => sessionRevoked(webRuntime(), ref))
const adapters: AppSessionAdapterFactories = {}
adapters["convex-better-auth"] = () => betterAuthRuntime().adapter // kit:convex-better-auth
root.value(SESSION_ADAPTERS, adapters)
}
// kit:profile-other:begin
/**
* kit-tenancy's announcement of a committed grant change (a role, a membership, an assignment): to
* this process's open live streams of the organization (live-grants.ts), and a failed announcement
* to the log with the request's trace -- the write is done, so it is reported and never thrown.
*/
function bindGrantChanges(root: Container, observe: ServerObservability): void {
root.value(GRANTS_CHANGED, liveGrantsChanged)
root.value(GRANTS_CHANGED_FAILED, ({ error }) =>
observe.log.error(
`live: a grant change was not announced to the open streams: ${error instanceof Error ? error.name : "failed"}`,
),
)
}
// kit:profile-other:end
/** This profile's sign-in: the host's on Postgres, the same code over the profile's data source otherwise. */
export function hostSignIn(): HostSignIn {
if (PROFILE === "postgres") return postgresHost().signIn
signIn ??= createHostSignIn({
runtime: webRuntime as () => WebRuntime & { data?: DataSource & SystemDataSource },
// An invitation link's sign-in calls the invitation procedures through the request's own caller.
invitations: () =>
invitationCallsOf((request) => {
if (!invitationContext) throw new Error("an invitation's sign-in is answered through authRoute")
return invitationContext(request)
}),
})
return signIn
}
/** The identity link's admission client (ADR-062 decision 4), built on first use, or why there is none. */
export function identityAdmission(): IdentityAdmission | { readonly skipped: string } {
return hostSignIn().admission()
}
/** `/auth/*` of this profile: what `src/routes/auth.$.ts` answers, with request.ts's request context. */
export function authRoute(request: Request, context: Parameters<typeof invitationCallsOf>[0]): Promise<Response> {
invitationContext ??= context
return hostSignIn().handle(request)
}
/** A request's context on a profile whose procedures run in this process. */
export function kernelContext(): ReturnType<typeof kernelRequestContext> {
if (PROFILE === "postgres") return postgresHost().requestContext
kernel ??= kernelRequestContext({
runtime: webRuntime as () => WebRuntime & { data?: DataSource & SystemDataSource },
signIn: hostSignIn(),
rolesChecked,
})
return kernel
}
/**
* Resolves once the two connections this process holds have been checked (decision AT-D20). A
* request awaits it, so no request runs through a connection that is not bound by row-level
* security; `/api/health` does not, because liveness must not depend on the database.
*/
export function rolesChecked(): Promise<void> {
webRuntime()
return checked ?? Promise.resolve()
}
// kit:profile-convex:begin
/** Check that this backend has the exact generated application contract before taking traffic. */
export async function convexCapabilitiesReady(): Promise<boolean> {
const current = webRuntime()
if (!current.convex) return true
try {
return (await convexCapabilityStatus(current)).ready
} catch {
return false
}
}
export interface ConvexCapabilityStatus {
readonly ready: boolean
readonly backend: boolean
readonly modules: boolean
readonly procedures: boolean
readonly actions: boolean
readonly schema: boolean
}
export async function convexCapabilityStatus(current: WebRuntime = webRuntime()): Promise<ConvexCapabilityStatus> {
if (!current.convex)
return { ready: true, backend: true, modules: true, procedures: true, actions: true, schema: true }
const client = new ConvexHttpClient(current.convex.url)
type AdminAuthClient = ConvexHttpClient & { setAdminAuth(token: string): void }
;(client as AdminAuthClient).setAdminAuth(current.convex.adminKey)
try {
const actual = (await client.query(makeFunctionReference("system:ready") as never, {} as never)) as {
ready?: boolean
modules?: string[]
procedures?: string[]
actions?: string[]
tables?: string[]
schema?: unknown
}
const actionProbe = (await client.action(
makeFunctionReference("filesActions:readiness") as never,
{} as never,
)) as {
ready?: boolean
}
const modules = current.registry.modules.map(({ id }) => id).sort()
const procedures = Object.keys(CONVEX_PROCEDURES).sort()
const tables = entitiesOf(current.registry, "convex")
.map(({ name }) => name)
.sort()
const schema = entitiesOf(current.registry, "convex")
.map((entity) => ({
name: entity.name,
table: entity.table,
tenancy: entity.tenancy,
columns: [...entity.columns].sort(),
unique: entity.unique.map((fields) => [...fields]),
indexes: entity.indexes.map((fields) => [...fields]),
}))
.sort((left, right) => left.name.localeCompare(right.name))
const equal = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right)
const checks = {
backend: actual.ready === true,
modules: equal(actual.modules, modules),
procedures: equal(actual.procedures, procedures),
actions:
actionProbe.ready === true &&
equal(actual.actions, [
"filesActions:begin",
"filesActions:complete",
"filesActions:remove",
"filesActions:cleanupDelete",
"filesActions:recoverUpload",
"filesActions:readiness",
]),
schema: equal(actual.tables, tables) && equal(actual.schema, schema),
}
return { ...checks, ready: Object.values(checks).every(Boolean) }
} catch {
return { ready: false, backend: false, modules: false, procedures: false, actions: false, schema: false }
}
}
async function verifyConvexCapabilities(current: WebRuntime): Promise<void> {
if (!(await convexCapabilityStatus(current)).ready) {
throw new Error("Convex deployment does not expose the required module, procedure, action and schema capabilities")
}
}
// kit:profile-convex:end
/** The sign-in backend-for-frontend (spec §7), built on first use, so `/api/health` answers without it. */
export function authRoutes(): Promise<AuthRoutes> {
return hostSignIn().authRoutes()
}
/**
* A revoked app session ends the derived service sessions minted for it (joint contract §14), whoever
* reports it: kit-auth for an OIDC session, the Better Auth sign-out and revocation relay for one of
* Better Auth's. Durable on both profiles: the account module's revocation outbox, retried by the
* worker on Postgres and by the backend's one-minute cron on Convex (convex/account.ts).
*/
export function sessionRevoked(current: WebRuntime, ref: SessionRef): Promise<void> {
const convex = current.convex // kit:profile-convex
return revokeDerivedSessions(
{
registry: current.registry,
root: current.root,
log: current.observe.log,
...(convex ? { durable: (session: string) => revokeConvexSession(convex, session) } : {}), // kit:profile-convex
...(current.data
? {
durable: (session: string) => recordSessionRevocation(current.root, session, { log: current.observe.log }),
}
: {}),
},
ref,
)
}
// kit:convex-better-auth:begin
/**
* The Convex Better Auth adapter of this process (AUTH_PROVIDER=convex-better-auth), built on first
* use with its revocation relay started (src/server/better-auth.ts). Refused by name in a deployment
* that selected another provider.
*/
export function betterAuthRuntime(): betterAuthAdapter.BetterAuthRuntime {
if (betterAuth) return betterAuth
const config = loadAuthConfig()
if (config.provider !== "convex-better-auth") {
throw new Error(`AUTH_PROVIDER=${config.provider} is selected; the Convex Better Auth adapter is not in use`)
}
const current = webRuntime()
const identities = current.authData ?? current.data
if (!identities) throw new Error("The selected app profile has no auth data source")
betterAuth = betterAuthAdapter.createBetterAuthRuntime({
config,
identities,
onSessionRevoked: (ref) => sessionRevoked(current, ref),
// The account module's flow: in this process on Postgres, in the backend's actions on Convex.
onAccountEvent: (event) => {
const convex = current.convex
return betterAuthAdapter.deliverAccountEvent(
{
accountFlow: convex
? (flowEvent) => deliverConvexAccountEvent(convex, flowEvent)
: betterAuthAdapter.localAccountFlow(current.root),
},
event,
)
},
log: current.observe.log,
})
return betterAuth
}
// kit:convex-better-auth:end
/** The one SG-11 session adapter, selected by AUTH_PROVIDER (`createHostSignIn`). */
export function appSession(): Promise<AppSessionAdapter> {
return hostSignIn().appSession()
}
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a failed sign-in becomes is `@conusai-kit/host/postgres`'s (spec §7): a short code in the URL of
// the sign-in page, never the provider's text. What is here is which words a page shows for each code.
import type { AuthError } from "@conusai-kit/host/postgres"
export {
AUTH_ERRORS,
type AuthError,
authErrorOf,
parseAuthError,
refusalOf,
safeReturnTo,
signInErrorHref,
} from "@conusai-kit/host/postgres"
/** The message key of each code, so a page asks for them by name and a test can hold both catalogues to it. */
export const AUTH_ERROR_MESSAGES: Readonly<Record<AuthError, string>> = {
sessionExpired: "signin.error.sessionExpired",
noTransaction: "signin.error.noTransaction",
denied: "signin.error.denied",
provider: "signin.error.provider",
failed: "signin.error.failed",
}
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What the sign-out route takes from the Convex Better Auth adapter of this process
// (app.ts `betterAuthRuntime()`, AUTH_PROVIDER=convex-better-auth). `kit template:prune --auth oidc`
// removes this file with the lines that call it.
import { loadAuthConfig } from "@conusai-kit/auth"
import { betterAuthRuntime } from "./app"
/**
* POST /auth/sign-out with Better Auth selected: its session ends at Better Auth, with the device's
* derived service sessions. Null when another provider is selected, whose sign-out is kit-auth's.
*/
export async function betterAuthSignOut(request: Request): Promise<Response | null> {
if (loadAuthConfig().provider !== "convex-better-auth") return null
return betterAuthRuntime().signOut(request)
}
+227
View File
@@ -0,0 +1,227 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The Convex Better Auth adapter of this app (AUTH_PROVIDER=convex-better-auth, joint contract §14),
// in one place: the session adapter app.ts registers (its `betterAuthRuntime()` builds this once per
// process), the same-origin proxy of Better Auth's browser endpoints (/api/auth, src/routes/api.$.ts), the sign-out, the
// revocation relay, and the end of a person's Better Auth account when the app's own account deletion
// runs. `kit template:prune --auth oidc` removes this file with every line that names it.
//
// Better Auth itself runs in this app's Better Auth Convex deployment (better-auth/functions). This
// server imports no Better Auth package: it validates every request's session there, through
// @conusai-kit/auth-convex-better-auth, and holds no session of its own.
//
// BETTER_AUTH_CONVEX_URL the deployment's URL: the session validation query and the relay
// BETTER_AUTH_CONVEX_SITE_URL its site URL: Better Auth's HTTP routes
// BETTER_AUTH_CONVEX_ADMIN_KEY its admin key, server-only: the relay and the account's end
// BETTER_AUTH_RELAY_INTERVAL_MS how often the relay drains the revocation outbox (default 2000)
import { publicUrlOf } from "@app/config"
import { ACCOUNT_IDENTITY_PROVIDER, recordProviderAccountEvent } from "@app/modules/account"
import type { AppSessionAdapter, AuthConfig, SessionRef, SessionRevocationHook } from "@conusai-kit/auth"
import {
type AccountLifecycleEvent,
type BetterAuthRevocationRelay,
betterAuthAccountEnd,
betterAuthAccountEndFromEnv,
clearedBetterAuthCookies,
createBetterAuthProxy,
createBetterAuthRevocationRelay,
createConvexBetterAuthSessionAdapter,
signOutBetterAuthSession,
} from "@conusai-kit/auth-convex-better-auth"
import type { Container } from "@conusai-kit/core"
import type { DataSource } from "@conusai-kit/data"
export const DEFAULT_RELAY_INTERVAL_MS = 2000
/** Better Auth's `minPasswordLength` (better-auth/functions/betterAuth/options.ts), for the form's own check. */
export const MIN_PASSWORD_LENGTH = 12
/** The labels of the sign-in and sign-up form (src/ui/better-auth-form.tsx), in the page's language. */
export function betterAuthFormLabels(
i18n: { message(key: string, values?: Record<string, string | number>): string },
mode: "sign-in" | "sign-up",
) {
const errors = ["invalid", "exists", "disabled", "rateLimited", "unavailable", "failed"] as const
return {
name: i18n.message("signin.account.name"),
email: i18n.message("app.email"),
password: i18n.message("signin.account.password"),
passwordHelp: i18n.message("signin.account.passwordHelp", { min: MIN_PASSWORD_LENGTH }),
submit: i18n.message(mode === "sign-in" ? "signin.account.signIn" : "signin.account.signUp"),
errors: Object.fromEntries(errors.map((code) => [code, i18n.message(`signin.account.error.${code}`)])) as Record<
(typeof errors)[number],
string
>,
}
}
export interface BetterAuthEnv {
readonly url: string
readonly siteUrl: string
readonly adminKey: string
readonly relayIntervalMs: number
}
const absolute = (name: string, value: string | undefined): string => {
const trimmed = value?.trim()
if (!trimmed)
throw new Error(`${name} is not set: AUTH_PROVIDER=convex-better-auth needs this app's Better Auth deployment`)
try {
const url = new URL(trimmed)
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error()
} catch {
throw new Error(`${name} is not an absolute http(s) URL`)
}
return trimmed.replace(/\/+$/, "")
}
/** The deployment contract of the adapter, refused by name like the rest of the environment. */
export function betterAuthEnv(env: Record<string, string | undefined> = process.env): BetterAuthEnv {
const url = absolute("BETTER_AUTH_CONVEX_URL", env.BETTER_AUTH_CONVEX_URL)
const siteUrl = absolute("BETTER_AUTH_CONVEX_SITE_URL", env.BETTER_AUTH_CONVEX_SITE_URL)
const adminKey = env.BETTER_AUTH_CONVEX_ADMIN_KEY?.trim()
if (!adminKey) {
throw new Error("BETTER_AUTH_CONVEX_ADMIN_KEY is not set: the revocation relay reads the outbox with it")
}
const rawInterval = env.BETTER_AUTH_RELAY_INTERVAL_MS?.trim()
const relayIntervalMs = rawInterval ? Number(rawInterval) : DEFAULT_RELAY_INTERVAL_MS
if (!Number.isSafeInteger(relayIntervalMs) || relayIntervalMs < 100) {
throw new Error("BETTER_AUTH_RELAY_INTERVAL_MS is not a whole number of milliseconds of at least 100")
}
return { url, siteUrl, adminKey, relayIntervalMs }
}
export interface BetterAuthRuntime {
readonly adapter: AppSessionAdapter
readonly relay: BetterAuthRevocationRelay
/** The handler of /api/auth/* (src/routes/api.$.ts): Better Auth's browser endpoints, same-origin. */
proxy(request: Request): Promise<Response>
/** POST /auth/sign-out: ends this device's session at Better Auth and its derived service sessions. */
signOut(request: Request): Promise<Response>
/** Deletes a Better Auth user (sessions first), by its Better Auth user ID. Idempotent. */
endAccount(providerSubject: string): Promise<void>
/** Stops the relay (a test, a shutdown). */
stop(): Promise<void>
}
export interface BetterAuthRuntimeOptions {
readonly config: Pick<AuthConfig, "provider" | "identityNamespace">
/** The kit data source that holds the identity mapping (app_user_identity). */
readonly identities: DataSource
/** The app's public origin: what Better Auth trusts, and where the sign-out sends the browser. Default CONUSAI_PUBLIC_URL. */
readonly publicUrl?: string
/** The derived service sessions of one device end with it (app.ts: the account module's outbox). */
readonly onSessionRevoked: SessionRevocationHook
/** Better Auth's account deletion and an administrator's disablement (app.ts: the account flow). */
readonly onAccountEvent: (event: AccountLifecycleEvent) => Promise<void>
readonly log: { warn(message: string): void }
readonly env?: Record<string, string | undefined>
readonly fetch?: typeof globalThis.fetch
}
export function createBetterAuthRuntime(options: BetterAuthRuntimeOptions): BetterAuthRuntime {
const env = betterAuthEnv(options.env)
const transport = options.fetch ? { fetch: options.fetch } : {}
const adapter = createConvexBetterAuthSessionAdapter({
config: options.config,
convexUrl: env.url,
convexSiteUrl: env.siteUrl,
identities: options.identities,
...transport,
})
const relay = createBetterAuthRevocationRelay({
config: options.config,
convexUrl: env.url,
adminKey: env.adminKey,
identities: options.identities,
onSessionRevoked: options.onSessionRevoked,
onAccountEvent: options.onAccountEvent,
// A row that failed stays pending and is retried on the next drain; the reason is logged, never a value.
onError: (error, row) =>
options.log.warn(
`better-auth: the revocation relay ${row ? `could not deliver a ${row.reason} event (attempt ${row.attempts + 1})` : "could not drain the outbox"}: ${error instanceof Error ? error.message : "failed"}`,
),
...transport,
})
const proxy = createBetterAuthProxy({ convexSiteUrl: env.siteUrl, ...transport })
const origin = new URL(options.publicUrl ?? publicUrlOf(options.env)).origin
const stopRelay = relay.start(env.relayIntervalMs)
const signOut = async (request: Request): Promise<Response> => {
// The session this device holds, so its derived service sessions end with it; an unreachable
// provider must not keep the person signed in on this device, so the cookies go regardless.
let ref: SessionRef | null = null
try {
ref = (await adapter.resolve(request))?.sessionRef ?? null
} catch {
options.log.warn("better-auth: the session being signed out could not be validated; its cookies are cleared")
}
if (ref) await options.onSessionRevoked(ref, "sign-out")
if (!(await signOutBetterAuthSession(request, { convexSiteUrl: env.siteUrl, origin, ...transport }))) {
options.log.warn(
"better-auth: Better Auth did not confirm a sign-out; the relay reports the session when it ends",
)
}
const headers = new Headers({ location: `${origin}/auth/signed-out`, "cache-control": "no-store" })
for (const cookie of clearedBetterAuthCookies()) headers.append("set-cookie", cookie)
return new Response(null, { status: 303, headers })
}
const { endAccount } = betterAuthAccountEnd({
convexUrl: env.url,
adminKey: env.adminKey,
// The runtime ends an account by its Better Auth user ID; the namespace is the mapping's, for a deletion.
identityNamespace: options.config.identityNamespace ?? "",
...transport,
})
return { adapter, relay, proxy, signOut, endAccount, stop: stopRelay }
}
/** An account event, as the account module's flow takes it: the mapped app user. */
export interface AccountFlowEvent {
readonly reason: AccountLifecycleEvent["reason"]
readonly userId: string
}
export interface AccountEventPorts {
/**
* The account module's flow for the event (app.ts): in this process on the Postgres profile
* (`recordProviderAccountEvent`), in the backend on the Convex profile (convex/account.ts).
*/
readonly accountFlow: (event: AccountFlowEvent) => Promise<void>
}
/**
* An account event of Better Auth, from the revocation relay: the person deleted their Better Auth
* account, or an administrator disabled it. On both profiles the account module's flow runs it: an
* erasure through its deletion ledger (resumed by the sweep), or the revocation of every credential
* and derived service session of the person. A subject that never signed in to this app has no app
* user, and nothing here to end. A failure of the flow is thrown, so the relay keeps the row and
* delivers it again.
*/
export async function deliverAccountEvent(ports: AccountEventPorts, event: AccountLifecycleEvent): Promise<void> {
if (event.kitUserId === null) return
await ports.accountFlow({ reason: event.reason, userId: event.kitUserId })
}
/** The Postgres profile's flow: the account module in this process, in a child of its container. */
export const localAccountFlow =
(root: Container) =>
(event: AccountFlowEvent): Promise<void> =>
recordProviderAccountEvent(root.child(), event)
/**
* Better Auth owns the person's accounts: every account deletion this process runs or resumes ends
* the person's Better Auth account too, found by the identity mapping, before the identity is
* detached, so a live Better Auth session cannot go on as a new, empty user. Bound in the process
* container (app.ts), as the worker binds it for its sweep: the deletion ledger records the provider,
* and a process without it would hold the deletion at that step. Any other provider binds nothing.
*/
export function bindBetterAuthAccountEnd(
container: Container,
env: Record<string, string | undefined> = process.env,
): void {
if (env.AUTH_PROVIDER?.trim() !== "convex-better-auth") return
container.register(ACCOUNT_IDENTITY_PROVIDER, () => betterAuthAccountEndFromEnv(env))
}
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Where the brand's logos are read from for a share image and an email: the app brand's from the
// app's own public folder, an organization's through the same caller every page uses (so only a
// member reads it). The branding module decides which one is drawn (`shareBrand`); this file only
// fetches bytes, and the module's kit checks decide whether they are a logo.
import { existsSync } from "node:fs"
import { readFile } from "node:fs/promises"
import { createRequire } from "node:module"
import path from "node:path"
import { type AppBrand, appBrand } from "@app/config"
import { type AppIdentity, logoGet, type ShareReaders } from "@app/modules/branding"
import type { LogoSource } from "@conusai-kit/brand/logo/check"
import type { TransportCaller } from "@conusai-kit/core"
const TYPES: Readonly<Record<string, string>> = { ".png": "image/png", ".webp": "image/webp", ".svg": "image/svg+xml" }
const KIT_FAVICON = "/favicon.png"
const KIT_FAVICON_FILE = "@conusai-kit/brand/logo/favicon.png"
/** The app's public folder: `node server.mjs` and `vite dev` run in apps/web. */
export const publicDirectory = (): string => path.join(process.cwd(), "public")
/** A file of the public folder as a logo source. The path is the app brand's (`defineBrand` keeps it on this origin); it is still held inside the folder. */
export async function appLogoSource(logoPath: string, directory: string = publicDirectory()): Promise<LogoSource> {
const contentType = TYPES[path.extname(logoPath).toLowerCase()]
if (contentType === undefined) throw new Error("the app brand's logo is a PNG, WebP or SVG file")
const root = path.resolve(/*turbopackIgnore: true*/ directory)
const file = path.resolve(/*turbopackIgnore: true*/ root, `.${logoPath}`)
if (!file.startsWith(`${root}${path.sep}`)) throw new Error("the app brand's logo is a file of the public folder")
// The tab icon the build emits (vite.config.ts) is not in the public folder: it is the kit's own file.
const theKits = logoPath === KIT_FAVICON && !existsSync(file)
const source = theKits ? createRequire(import.meta.url).resolve(KIT_FAVICON_FILE) : file
return { contentType, bytes: new Uint8Array(await readFile(/*turbopackIgnore: true*/ source)) }
}
export const appIdentity = (brand: AppBrand = appBrand): AppIdentity => ({
name: brand.name,
logo: { light: brand.logo.light, dark: brand.logo.dark },
})
/** The readers for an organization's page: its own logos through the caller, the app's from the public folder. */
export function readersFor(caller: TransportCaller, org: string, directory?: string): ShareReaders {
return {
appLogo: (logoPath) => appLogoSource(logoPath, directory),
async tenantLogo(variant) {
const logo = await caller.call(logoGet, { org, variant })
return { contentType: logo.contentType, bytes: new Uint8Array(Buffer.from(logo.body, "base64")) }
},
}
}
/** The readers for the app's own card: no organization, so an organization's logo is never asked for. */
export const appOnlyReaders = (directory?: string): ShareReaders => ({
appLogo: (logoPath) => appLogoSource(logoPath, directory),
tenantLogo: () => Promise.reject(new Error("the app's own card has no organization")),
})
+137
View File
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The brand's images a route answers: the app's own share image, an organization's share image and an
// organization's logo. Server-only: the route files import this on the first request.
import { appBrand, identityBrandDocument } from "@app/config"
import { brandGet, logoGet, renderShareImage, shareBrand } from "@app/modules/branding"
import { AppError } from "@conusai-kit/core"
import { orgGet } from "@conusai-kit/tenancy"
import { webRuntime } from "./app"
import { appIdentity, appOnlyReaders, readersFor } from "./brand-assets"
import { pageContext } from "./page"
import { cloudOrganizationLogo, organizationIdentity, organizationThemeRoles } from "./platform-theme"
let drawn: Promise<Buffer<ArrayBuffer>> | undefined
/**
* The app's own share image (Open Graph and Twitter card, spec §10.1): the app brand's name and logo in the
* brandbook's treatment, 1200x630. It is public, because a link preview is fetched without a session, and it
* carries nothing of any organization. The image only changes when the brand file does, so it is drawn once
* per process.
*/
export async function appShareImage(): Promise<Response> {
try {
drawn ??= (async () => {
const share = await shareBrand({ app: appIdentity(), tenant: null, readers: appOnlyReaders() })
return Buffer.from(await renderShareImage(share, { title: appBrand.name }))
})()
return new Response(await drawn, {
headers: {
"content-type": "image/png",
"x-content-type-options": "nosniff",
"cache-control": "public, max-age=3600",
},
})
} catch {
drawn = undefined
return new Response(null, { status: 500 })
}
}
/**
* What a managed identity service's hosted sign-in draws (`images/better-auth/src/brand/app-brand.ts`): this app's
* name, logo, picture and words, and its organization's theme. It is public, because the service reads it with no
* session, and it carries nothing a sign-in page does not show.
*/
export async function identityBrand(): Promise<Response> {
const deployment = webRuntime().env.deployment
const [theme, organization] = await Promise.all([
organizationThemeRoles(deployment),
organizationIdentity(deployment),
])
return Response.json(identityBrandDocument(appBrand, theme ?? undefined, organization), {
headers: { "x-content-type-options": "nosniff", "cache-control": "public, max-age=60" },
})
}
/**
* The deployment's organization's logo from Cloud, from this app's origin, so the page's image policy stays `'self'`.
* Public: the sign-in shows it before anyone is signed in. An SVG is served as an image and runs nothing.
*/
export async function organizationLogoImage(): Promise<Response> {
const found = await cloudOrganizationLogo(webRuntime().env.deployment)
if (!found) return new Response(null, { status: 404 })
return new Response(found.bytes as Uint8Array<ArrayBuffer>, {
headers: {
"content-type": found.type,
"x-content-type-options": "nosniff",
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
"cache-control": "public, max-age=300",
},
})
}
/**
* An organization's share image (spec §10.3): its display name and its logo for the dark page (its own, or
* the one it has when it has only one, or the app brand's), in the brandbook's treatment, 1200x630.
*
* It goes through the same caller as every page, so only a member of the organization draws it, and a
* non-member and a missing organization get the same 404. The logo is checked by the kit's rules wherever
* it comes from; one that cannot be drawn is replaced by the app brand's, and the image is still served.
*/
export async function organizationShareImage(org: string): Promise<Response> {
try {
const { caller } = await pageContext()
const organization = await caller.call(orgGet, { org })
const brand = await caller.call(brandGet, { org })
const share = await shareBrand({
app: appIdentity(),
tenant: { name: organization.name, brand },
readers: readersFor(caller, org),
})
const png = await renderShareImage(share, { title: share.name, footer: appBrand.name })
return new Response(Buffer.from(png), {
headers: {
"content-type": "image/png",
"x-content-type-options": "nosniff",
"cache-control": "private, max-age=300",
},
})
} catch (error) {
// A non-member, a missing organization and a logo nobody can draw are not told apart.
return new Response(null, { status: error instanceof AppError && error.status < 500 ? 404 : 500 })
}
}
/**
* An organization's logo, served to its members from this app's own origin.
*
* It goes through the same caller as every page, so only a member of the organization reads it
* (`branding.logo.get`, `branding.brand.read`), and what comes back is bytes a checker approved when
* they were uploaded. The response is still treated as hostile: `nosniff` so a browser does not
* reinterpret it, a content security policy that allows nothing -- no script, no request, no
* framing -- so even an SVG that slipped past the checker runs nothing when it is opened directly,
* and `sandbox` for the same reason.
*/
export async function organizationLogo(request: Request, org: string, variant: string): Promise<Response> {
if (variant !== "light" && variant !== "dark") return new Response(null, { status: 404 })
// `?s=1x` or `?s=2x`: the 1x or the 2x rendition of a raster logo (2x when absent; an SVG has one size).
const size = new URL(request.url).searchParams.get("s")
if (size !== null && size !== "1x" && size !== "2x") return new Response(null, { status: 404 })
try {
const { caller } = await pageContext(request)
const logo = await caller.call(logoGet, { org, variant, ...(size === null ? {} : { size }) })
return new Response(Buffer.from(logo.body, "base64"), {
headers: {
"content-type": logo.contentType,
"x-content-type-options": "nosniff",
"content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
// The logo's path carries its own content tag (`?v=`), so a changed logo is a new URL.
"cache-control": "private, max-age=31536000, immutable",
},
})
} catch (error) {
// A non-member, a missing organization and a missing logo are all the same answer.
return new Response(null, { status: error instanceof AppError && error.status < 500 ? 404 : 500 })
}
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a web process answers when the deployment contract refuses it: the process stays up, so `/api/ready` can say
// why and the deployment's health check fails with a reason, while the previous deployment keeps serving. Every other
// route is a plain 503, not the 500 of an exception on each request. And the origin the contract adds to the content
// security policy.
export interface ContractRefusal {
/** The whole message, for the process's log. */
readonly message: string
/** What `/api/ready` may say in public. A message that quotes a value is held back: the value is a customer's. */
readonly reason: string
}
export function refusalOf(error: unknown): ContractRefusal {
const message = error instanceof Error ? error.message : String(error)
const reason = message.includes('"') ? "the deployment contract refused this process; its log says why" : message
return { message, reason }
}
export function refusalResponse(request: Request, refusal: ContractRefusal): Response {
const body =
new URL(request.url).pathname === "/api/ready"
? { ready: false, checks: { contract: false }, reason: refusal.reason }
: { status: 503, code: "not_configured" }
return Response.json(body, { status: 503, headers: { "cache-control": "no-store", "retry-after": "30" } })
}
/**
* Where a form may end besides this app. `/auth/sign-out` is a POST the server answers with a redirect to the provider's
* end-session endpoint, and browsers apply `form-action` to that redirect. `loadAuthConfig` has refused an issuer that is
* not a URL.
*/
export function issuerOrigins(issuer: string | undefined): readonly string[] {
const named = issuer?.trim()
return named ? [new URL(named).origin] : []
}
+123
View File
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The Next BFF's narrow kit-auth storage port over private Convex functions. Every operation is
// one query or mutation (and therefore one Convex transaction); domain procedures never use this
// adapter and are dispatched to their generated wrapper so multi-row work stays atomic.
import { apiKeys, sessions } from "@conusai-kit/auth/entities"
import { internal } from "@conusai-kit/core"
import type {
DataSource,
EntityBase,
EntityDef,
EntityTenancy,
GlobalRepository,
ReadOnlyRepository,
SystemDataSource,
} from "@conusai-kit/data"
import type { AppUserResolverDataSource } from "@conusai-kit/tenancy"
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference, type UserIdentityAttributes } from "convex/server"
type Args = Record<string, unknown>
type AdminAuthClient = ConvexHttpClient & {
/** Convex SDK 1.45.0 internal server transport, also used by `convex run --identity`. */
setAdminAuth(token: string, identity?: UserIdentityAttributes): void
}
type Key =
| "appUserResolve"
| "identityMappingsApply"
| "identityIssuerCoverage"
| "sessionById"
| "sessionByHandleHash"
| "sessionInsert"
| "sessionUpdate"
| "apiKeyByHash"
const ref = (name: string) => makeFunctionReference(name)
const notSupported = (what: string): never => {
throw internal(`Convex BFF auth storage does not support ${what}`)
}
const oneItem = <T>(item: T | null) => ({ items: item ? [item] : [], hasMore: false })
/**
* Only the user and session global repositories and the API-key hash lookup are implemented.
* Anything outside that auth contract fails closed instead of silently becoming a non-atomic
* collection of backend mutations.
*/
export function createConvexAuthDataSource(options: {
url: string
adminKey: string
}): AppUserResolverDataSource & SystemDataSource {
const client = new ConvexHttpClient(options.url)
;(client as AdminAuthClient).setAdminAuth(options.adminKey)
const query = <T>(name: Key, args: Args) => client.query(ref(`system:${name}`) as never, args as never) as Promise<T>
const mutation = <T>(name: Key, args: Args) =>
client.mutation(ref(`system:${name}`) as never, args as never) as Promise<T>
const global = <E extends EntityBase>(entity: EntityDef<E>): GlobalRepository<E> => {
if ((entity as unknown) !== sessions) return notSupported(`global entity ${entity.name}`)
const list = async (input: Parameters<GlobalRepository<E>["list"]>[0] = {}) => {
const filters = (input?.where ?? []) as unknown as ReadonlyArray<{ field: string; value?: unknown }>
const handleHash = filters.find((f) => f.field === "handleHash")?.value
if (typeof handleHash !== "string") return notSupported("session list without a handleHash filter")
return oneItem(await query<E>("sessionByHandleHash", { handleHash }))
}
return {
get: (id) => query<E | null>("sessionById", { id }),
require: async (id) => {
const row = await query<E | null>("sessionById", { id })
if (!row) throw internal(`no ${entity.name} ${id}`)
return row
},
list,
count: async (input) => (await list({ where: input?.where } as never)).items.length,
insert: (input) => mutation<E>("sessionInsert", { input }),
update: (id, patch, expectedVersion) => mutation<E>("sessionUpdate", { id, patch, expectedVersion }),
delete: () => notSupported(`${entity.name}.delete`),
} as GlobalRepository<E>
}
const system: SystemDataSource = {
acrossTenants<E extends EntityBase, T extends EntityTenancy>(entity: EntityDef<E, T>): ReadOnlyRepository<E, T> {
if ((entity as unknown) !== apiKeys) return notSupported(`cross-tenant reads of ${entity.name}`)
const list = async (input: Parameters<ReadOnlyRepository<E, T>["list"]>[0] = {}) => {
const filters = (input?.where ?? []) as unknown as ReadonlyArray<{ field: string; value?: unknown }>
const tokenHash = filters.find((filter) => filter.field === "tokenHash")?.value
if (typeof tokenHash !== "string") return notSupported("API-key list without a tokenHash filter")
return oneItem(await query<E>("apiKeyByHash", { tokenHash }))
}
return {
get: () => notSupported("API-key reads by id"),
require: () => notSupported("API-key reads by id"),
list,
count: async (input) => (await list({ where: input?.where } as never)).items.length,
} as ReadOnlyRepository<E, T>
},
}
const unsupportedRepository = (): never => notSupported("tenant repositories from the Next BFF")
const source: AppUserResolverDataSource & SystemDataSource = {
global,
appUserResolver: {
resolve: (claims, options) => mutation("appUserResolve", { claims, create: options.create }),
applyMappings: (options) => mutation("identityMappingsApply", { options }),
issuerCoverage: (issuers) => query("identityIssuerCoverage", { issuers }),
},
forScope: unsupportedRepository as DataSource["forScope"],
unitOfWork: { run: unsupportedRepository as DataSource["unitOfWork"]["run"] },
outbox: {
pending: unsupportedRepository,
markPublished: unsupportedRepository,
markFailed: unsupportedRepository,
defer: unsupportedRepository,
markStuck: unsupportedRepository,
},
idempotency: { remember: unsupportedRepository },
readOnly: () => source,
close: async () => undefined,
acrossTenants: system.acrossTenants,
}
return source
}
+287
View File
@@ -0,0 +1,287 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Server-only procedure transport for the Convex profile. The browser still uses the same Hono
// BFF and session cookie as SQL profiles; the linked admin key never crosses that boundary.
//
// A write (a generated tenant mutation) is called with `LIVE_TXID_ARG`, so it answers its live log
// position with its value; the position is recorded with the router's capture and answered as
// `ConusAI-Txid`, as a Postgres transaction id is (umbrella TS6.3).
import type { AccountCredential } from "@app/modules/account"
import type { Principal } from "@conusai-kit/auth"
import type { CallOptions, TransportCaller } from "@conusai-kit/core"
import { AppError, internal } from "@conusai-kit/core"
import { LIVE_TXID_ARG, type LiveTxidEnvelope } from "@conusai-kit/data-convex/runtime"
import { recordTxid } from "@conusai-kit/data-kysely"
import { type FileScope, PartGuard, platformObjectStore } from "@conusai-kit/files"
import type { ObjectStore as PlatformObjectStore } from "@conusai-kit/platform"
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference, type UserIdentityAttributes } from "convex/server"
import { ConvexError } from "convex/values"
import { CONVEX_PROCEDURES } from "../../../../convex/generated/procedures"
import { CONVEX_BFF_ISSUER } from "../../../../convex/lib/runtime"
const unavailableProcedure = (name: string): AppError =>
new AppError({
type: "/problems/not_found",
title: "Not found",
status: 404,
code: "not_found",
detail: `no procedure ${name}`,
})
type AdminAuthClient = ConvexHttpClient & {
/** Convex SDK 1.45.0 internal server transport, also used by `convex run --identity`. */
setAdminAuth(token: string, identity?: UserIdentityAttributes): void
}
/** A Convex refusal as the kernel's problem; anything else is an opaque internal error. */
export const asProblem = (error: unknown): AppError => {
if (error instanceof AppError) return error
if (error instanceof ConvexError && typeof error.data === "object" && error.data !== null) {
const value = error.data as Record<string, unknown>
if (
typeof value.type === "string" &&
typeof value.title === "string" &&
typeof value.status === "number" &&
typeof value.code === "string" &&
(value.detail === undefined || typeof value.detail === "string")
) {
return new AppError(value as unknown as ConstructorParameters<typeof AppError>[0])
}
}
return internal()
}
/** The account credential as identity claims: only for a person signed in to this app. */
function credentialClaims(credential: AccountCredential | undefined): Record<string, string> {
if (!credential?.interactive) return {}
return {
conusaiCredential: "interactive",
...(credential.sessionId ? { conusaiSessionId: credential.sessionId } : {}),
...(credential.signedInAt ? { conusaiSignedInAt: credential.signedInAt } : {}),
}
}
/**
* An account event of the app's identity provider (Better Auth's deletion or ban, from its revocation
* relay) on the Convex profile: the account module's flow runs in the backend (convex/account.ts
* `providerEvent`), called with the admin key and no identity. A failure is thrown, so the relay keeps
* the event and delivers it again.
*/
export async function deliverConvexAccountEvent(
convex: { url: string; adminKey: string },
event: { readonly reason: "account-deleted" | "user-disabled"; readonly userId: string },
): Promise<void> {
const client = convexAdminClient(convex)
try {
await client.action(makeFunctionReference<"action">("account:providerEvent") as never, event as never)
} catch (error) {
throw asProblem(error)
}
}
/** Who a request's Convex calls are made as (request.ts): the facts the BFF verified. */
export interface ConvexIdentityOptions {
readonly principal: Principal | null
readonly user?: { readonly email: string; readonly displayName: string }
/** What the account module asks of the credential (request.ts), carried as identity claims (convex/lib/runtime.ts). */
readonly credential?: AccountCredential
/** The request's app session (kit-auth `sessionRefKey`): a derived service session is minted for it. */
readonly identitySession?: string
}
/**
* The identity a request's Convex calls carry (convex/lib/runtime.ts `callContextOf` reads it), or
* undefined for a request with no principal: its calls are anonymous.
*/
export function convexIdentity(options: ConvexIdentityOptions): UserIdentityAttributes | undefined {
if (!options.principal) return undefined
const subject = options.principal.actor.userId
return {
issuer: CONVEX_BFF_ISSUER,
subject,
tokenIdentifier: `${CONVEX_BFF_ISSUER}|${subject}`,
...(options.user?.email ? { email: options.user.email } : {}),
...(options.user?.displayName ? { name: options.user.displayName } : {}),
...(options.principal.orgId ? { conusaiOrgId: options.principal.orgId } : {}),
...credentialClaims(options.credential),
...(options.identitySession ? { conusaiIdentitySession: options.identitySession } : {}),
}
}
/**
* A one-shot client holding the deployment's admin key, server-side only: with an identity it calls
* as that person; without one it may call the deployment's internal functions too.
*/
export function convexAdminClient(
convex: { readonly url: string; readonly adminKey: string },
identity?: UserIdentityAttributes,
): ConvexHttpClient {
const client = new ConvexHttpClient(convex.url)
;(client as AdminAuthClient).setAdminAuth(convex.adminKey, identity)
return client
}
/**
* A tenant mutation's answer when it was called with `LIVE_TXID_ARG`: the procedure's value, with
* the write's live log position recorded for the router's `ConusAI-Txid` (none when it wrote no
* collection row).
*/
export function unwrapLiveTxid(answer: unknown): unknown {
const envelope = answer as Partial<LiveTxidEnvelope> | null
if (typeof envelope !== "object" || envelope === null || !("value" in envelope) || !("txid" in envelope)) {
throw internal("a tenant mutation did not answer its live txid envelope")
}
if (typeof envelope.txid === "string") recordTxid(envelope.txid)
return envelope.value
}
export function createConvexCaller(options: {
url: string
adminKey: string
principal: Principal | null
objectStore: PlatformObjectStore
/** The request's identity (`convexIdentity`); absent for a request with no principal. */
identity?: UserIdentityAttributes
}): TransportCaller {
const client = convexAdminClient(options, options.identity)
const callByName = async (name: string, input: unknown, callOptions?: CallOptions): Promise<unknown> => {
if (name === "files.file.download") {
if (!options.principal) throw unavailableProcedure(name)
const value = input as { org: string; workspace: string; project: string; fileId: string }
try {
const info = (await client.query(
makeFunctionReference("files:downloadInfo") as never,
{
input: value,
} as never,
)) as {
storageKey: string
filename: string
contentType: string
size: number
scope: { orgId: string; workspaceId: string; projectId: string }
}
const scope: FileScope = { level: "project", actor: options.principal.actor, ...info.scope }
const object = await platformObjectStore({ blob: options.objectStore }).get(scope, info.storageKey)
if (!object) {
throw new AppError({
type: "/problems/not_found",
title: "Not found",
status: 404,
code: "not_found",
detail: "no such file",
})
}
const fallback = info.filename.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "download"
return new Response(object.body, {
status: 200,
headers: {
"content-type": info.contentType,
"content-length": String(info.size),
"content-disposition": `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(info.filename)}`,
"x-content-type-options": "nosniff",
"cache-control": "private, no-store",
},
})
} catch (error) {
throw asProblem(error)
}
}
if (name === "files.file.putPart") {
if (!options.principal) throw unavailableProcedure(name)
const value = input as {
org: string
workspace: string
project: string
fileId: string
part: number
body: ReadableStream<Uint8Array> | Uint8Array
}
try {
const info = (await client.query(
makeFunctionReference("files:partInfo") as never,
{
input: {
org: value.org,
workspace: value.workspace,
project: value.project,
fileId: value.fileId,
part: value.part,
},
} as never,
)) as {
uploadId: string
storageKey: string
part: number
size: number
contentType?: string
scope: { orgId: string; workspaceId: string; projectId: string }
}
const scope: FileScope = {
level: "project",
actor: options.principal.actor,
...info.scope,
}
const guard = new PartGuard({
expectedBytes: info.size,
...(info.contentType ? { contentType: info.contentType } : {}),
})
const store = platformObjectStore({ blob: options.objectStore })
try {
const sent = await store.putUploadPart(scope, {
key: info.storageKey,
uploadId: info.uploadId,
number: info.part,
size: info.size,
body: guard.wrap(value.body),
})
return { part: info.part, etag: sent.etag }
} catch (error) {
throw guard.violation ?? error
}
} catch (error) {
throw asProblem(error)
}
}
const fileAction = {
"files.file.beginUpload": "filesActions:begin",
"files.file.completeUpload": "filesActions:complete",
"files.file.delete": "filesActions:remove",
}[name]
if (fileAction) {
const fn = makeFunctionReference(fileAction) as never
try {
return await client.action(fn, { input } as never)
} catch (error) {
throw asProblem(error)
}
}
const definition = CONVEX_PROCEDURES[name as keyof typeof CONVEX_PROCEDURES]
if (!definition) throw unavailableProcedure(name)
const fn = makeFunctionReference(definition.reference) as never
const args = { input, ...(callOptions?.idempotencyKey ? { idempotencyKey: callOptions.idempotencyKey } : {}) }
try {
// Widened: an app without guides and support generates no effect procedure at all.
switch (definition.kind as "read" | "write" | "effect") {
case "read":
return await client.query(fn, args as never)
// Every write is a generated tenant mutation, the one kind of function that takes LIVE_TXID_ARG.
case "write":
return unwrapLiveTxid(await client.mutation(fn, { ...args, [LIVE_TXID_ARG]: true } as never))
// An effect of CONVEX_ACTION_EFFECT_MODULES (guides, support): a generated action that runs
// the kernel with the module's platform port (convex/lib/effects.ts).
case "effect":
return await client.action(fn, args as never)
}
} catch (error) {
throw asProblem(error)
}
}
return {
call: (contract, input, options) => callByName(contract.name, input, options),
callByName,
} as TransportCaller
}
+190
View File
@@ -0,0 +1,190 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The Convex profile's half of `POST /api/v1/orgs/{org}/live` (live.ts): the CLP/1 stream is served by
// @conusai-kit/live-server in this process, over the deployment's own functions (convex/live.ts):
//
// - the log: `live:head` and `live:changes` (internal queries) read with the admin key, and the wake-up
// is a reactive subscription to `live:head` per organization a stream reads (`convexLiveLog`
// refcounts it), over one websocket client opened by the first stream
// - who may stream what: `live:open`, a public query run under the caller's identity (the same claims
// every procedure call carries, convex-caller.ts), decides with the kernel's tenancy over the
// deployment's rows (`decideLiveGrants`); its refusal comes back as the live problem the Postgres
// profile answers (401 signed out, 404 not reached), and each heartbeat asks `live:recheck`
//
// The admin key stays in this process; the browser holds no Convex credential.
import {
type ConvexLiveHead,
type ConvexLiveRow,
type ConvexLiveSource,
type LiveAuthorization,
type LiveRecheck,
type OpenRequest,
problems,
type ResolvedTopic,
} from "@conusai-kit/live-server"
import { ConvexClient } from "convex/browser"
import { makeFunctionReference, type UserIdentityAttributes } from "convex/server"
import { ConvexError } from "convex/values"
import { asProblem, convexAdminClient } from "./convex-caller"
const OPEN = makeFunctionReference<"query">("live:open")
const RECHECK = makeFunctionReference<"query">("live:recheck")
const HEAD = makeFunctionReference<"query">("live:head")
const CHANGES = makeFunctionReference<"query">("live:changes")
/** What the source and the authorization ask of a one-shot client (`ConvexHttpClient`). */
export interface LiveQueryClient {
query(reference: never, args: never): Promise<unknown>
}
/** What the source asks of a subscribing client (`ConvexClient`, a websocket). */
export interface LiveWatchClient {
onUpdate(
reference: never,
args: never,
callback: (value: unknown) => unknown,
onError?: (error: Error) => unknown,
): () => void
close(): Promise<void>
}
type AdminAuthWatchClient = ConvexClient & {
/** Convex SDK 1.45.0 internal, as `ConvexHttpClient.setAdminAuth` (convex-caller.ts). */
setAdminAuth(token: string, identity?: UserIdentityAttributes): void
}
/**
* The log source over two clients: `admin` reads (it holds the admin key and no identity, so it may call
* the internal `live:head` and `live:changes`), and `watcher` opens the subscribing client on the first
* `watch`; `close` closes it.
*/
export function convexLiveSource(clients: {
readonly admin: LiveQueryClient
readonly watcher: () => LiveWatchClient
}): ConvexLiveSource {
let watcher: LiveWatchClient | undefined
return {
head: async (orgId) => (await clients.admin.query(HEAD as never, { orgId } as never)) as ConvexLiveHead,
changes: async (orgId, after, limit) =>
(await clients.admin.query(
CHANGES as never,
{ orgId, after: { txid: after.txid, seq: after.seq }, limit } as never,
)) as {
rows: readonly ConvexLiveRow[]
head: ConvexLiveHead
more: boolean
},
watch(orgId, onChange, onError) {
watcher ??= clients.watcher()
// Convex answers the current head first, then every new one: each is a wake-up, and a wake-up
// with nothing new is one empty read.
return watcher.onUpdate(HEAD as never, { orgId } as never, () => onChange(), onError)
},
async close() {
const open = watcher
watcher = undefined
await open?.close()
},
}
}
/** The log source over the linked deployment, with its admin key (env.convex). */
export function deploymentLiveSource(convex: { readonly url: string; readonly adminKey: string }): ConvexLiveSource {
return convexLiveSource({
admin: convexAdminClient(convex) as unknown as LiveQueryClient,
watcher: () => {
const client = new ConvexClient(convex.url, { unsavedChangesWarning: false })
;(client as AdminAuthWatchClient).setAdminAuth(convex.adminKey)
return client as unknown as LiveWatchClient
},
})
}
/** The verified caller of one stream, as live.ts resolves it from the request. */
export interface LiveCaller {
readonly userId: string
/** The session (or credential) key of the per-session stream limit. */
readonly session: string
/** The identity its Convex calls carry (convex-caller.ts `convexIdentity`). */
readonly identity: UserIdentityAttributes
}
export interface ConvexLiveAuthorizeOptions {
/** The request's caller: signed in (or the live 401) and its CSRF checked. */
caller(request: Request): Promise<LiveCaller>
/** A one-shot client that calls as `identity` (`convexAdminClient`). */
clientFor(identity: UserIdentityAttributes): LiveQueryClient
/** The org slug of `/api/v1/orgs/{org}/live`. */
orgSlugOf(request: Request): string
/** Where an unexpected re-check failure is reported (it does not end the stream). */
warn(message: string): void
}
/** The status of a refusal: a live problem's own, or the problem a Convex function threw. */
function statusOf(error: unknown): number | undefined {
if (error instanceof ConvexError) {
const status = (error.data as { status?: unknown } | null)?.status
return typeof status === "number" ? status : undefined
}
const status = (error as { status?: unknown } | null)?.status
return typeof status === "number" ? status : undefined
}
/** A refusal of `live:open` as the Postgres profile answers it; another Convex problem as the kernel's. */
function refusalOf(error: unknown): unknown {
if (!(error instanceof ConvexError)) return error
const status = statusOf(error)
if (status === 401) return problems.unauthenticated()
if (status === 404) return problems.notFound()
return asProblem(error)
}
interface OpenAnswer {
readonly org: string
readonly topics: readonly ResolvedTopic[]
readonly entities: readonly string[]
readonly denied: ReadonlyArray<{ readonly entity: string; readonly reason: string }>
}
/** The authorize function of `createLiveServer` on the Convex profile. */
export function convexLiveAuthorize(
options: ConvexLiveAuthorizeOptions,
): (request: Request, open: OpenRequest) => Promise<LiveAuthorization> {
return async (request, open) => {
const caller = await options.caller(request)
const slug = options.orgSlugOf(request)
// The validator of `live:open` takes these fields only.
const topics = open.topics.map((topic) => ({ ws: topic.ws, ...(topic.project ? { project: topic.project } : {}) }))
let answer: OpenAnswer
try {
answer = (await options
.clientFor(caller.identity)
.query(OPEN as never, { org: slug, topics, entities: [...open.entities] } as never)) as OpenAnswer
} catch (error) {
throw refusalOf(error)
}
return {
org: answer.org,
session: caller.session,
granted: { entities: answer.entities, topics: answer.topics },
denied: answer.denied,
// Every heartbeat and on a grant change: the credential is still valid, the caller still a member of
// every scope, and still holds the read permission of every entity the stream was granted.
recheck: async (): Promise<LiveRecheck> => {
try {
const again = await options.caller(new Request(request.url, { method: "GET", headers: request.headers }))
if (again.userId !== caller.userId) return "session"
return (await options
.clientFor(again.identity)
.query(RECHECK as never, { org: slug, topics, granted: [...answer.entities] } as never)) as LiveRecheck
} catch (error) {
const status = statusOf(error)
if (status === 401) return "session"
if (status === 403 || status === 404) return "membership"
options.warn(`live: the re-check failed: ${error instanceof Error ? error.name : "failed"}`)
return null
}
},
}
}
}
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The Convex profile's durable sign-out revocation (joint contract §14): the web hands a revoked app
// session to the backend's revocation outbox (convex/account.ts), server-only, with the admin key.
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference } from "convex/server"
type AdminAuthClient = ConvexHttpClient & {
/** Convex SDK 1.45.0 internal server transport, also used by `convex run --identity`. */
setAdminAuth(token: string): void
}
/** How long a sign-out waits for the backend to record a revocation before it asks the modules itself. */
export const CONVEX_REVOCATION_MS = 5000
/**
* A revoked app session on the Convex profile (joint contract §14): recorded in the backend's
* revocation outbox and delivered from there (convex/account.ts `revokeSession`, called with the
* admin key and no identity), retried by the backend's one-minute cron. Bounded: a refusal, or a
* backend that does not answer within `timeoutMs`, is thrown, and the caller asks the modules
* directly (derived-sessions.ts).
*/
export async function revokeConvexSession(
convex: { url: string; adminKey: string },
session: string,
options: { timeoutMs?: number } = {},
): Promise<void> {
const client = new ConvexHttpClient(convex.url)
;(client as AdminAuthClient).setAdminAuth(convex.adminKey)
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error("the backend did not record the revocation in time")),
options.timeoutMs ?? CONVEX_REVOCATION_MS,
)
})
try {
await Promise.race([
client.action(makeFunctionReference<"action">("account:revokeSession") as never, { session } as never),
timeout,
])
} finally {
clearTimeout(timer)
}
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The derived service sessions of one device (joint contract §14 "Lifecycle, CSP and revocation"):
// when kit-auth revokes an app session -- sign-out, a refused refresh, expiry -- every installed
// module's `tenancy.account` share revokes, server-side, the service sessions it minted for that
// session (a Support widget session records it as its parent). An account deletion revokes the
// subject's sessions itself, at its start (modules/account/core/deletion.ts).
//
// Durable on both profiles: the revocation is written to the account module's revocation outbox and
// delivered at once, and what a platform did not confirm is retried with backoff
// (modules/account/core/revocation.ts) -- by the worker on Postgres, and on Convex by the backend,
// whose `account:revokeSession` action records and delivers it and whose one-minute cron retries it
// (convex/account.ts). When nothing was recorded (a refused write, a backend that did not answer)
// the modules are asked directly, bounded, once.
import { type SessionRef, sessionRefKey } from "@conusai-kit/auth"
import type { Container, Registry } from "@conusai-kit/core"
import { accountDataOf } from "@conusai-kit/tenancy"
/** How long a sign-out waits for one module's revocation before it goes on. */
export const DERIVED_REVOCATION_MS = 3000
export interface DerivedSessionPorts {
readonly registry: Registry
/** The process container: a child of it resolves each module's platform port. */
readonly root: Container
readonly log: { warn(message: string): void }
readonly timeoutMs?: number
/**
* Records the revocation durably and delivers it (the account module's `recordSessionRevocation`),
* when this profile has the outbox. Throws only when nothing was recorded.
*/
readonly durable?: (session: string) => Promise<void>
}
/**
* Bounded and never thrown: kit-auth keeps a session live when its hook throws, which would fail the
* sign-out. With the outbox, a module that could not be reached is retried by the worker until its
* platform confirms. Without it, such a module is logged by name and its sessions end at their own
* expiry (at most 60 minutes); this app already refuses the revoked session's requests.
*/
export async function revokeDerivedSessions(ports: DerivedSessionPorts, ref: SessionRef): Promise<void> {
const session = sessionRefKey(ref)
if (ports.durable) {
try {
await ports.durable(session)
return
} catch {
ports.log.warn("auth: the revocation of an app session could not be recorded; asking the modules directly")
}
}
const timeoutMs = ports.timeoutMs ?? DERIVED_REVOCATION_MS
const contributions = accountDataOf(ports.registry).filter((contribution) => contribution.revokeSessions)
const settled = await Promise.allSettled(
contributions.map((contribution) => {
let timer: ReturnType<typeof setTimeout> | undefined
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("timed out")), timeoutMs)
})
return Promise.race([contribution.revokeSessions?.({ session, container: ports.root.child() }), timeout]).finally(
() => clearTimeout(timer),
)
}),
)
settled.forEach((result, index) => {
if (result.status === "rejected") {
ports.log.warn(
`auth: ${contributions[index]?.key ?? "a module"} did not revoke the sessions of a revoked app session`,
)
}
})
}
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The web shell's MariaDB store: the Kysely MySQL dialect over MARIADB_URL (the pool is pinned to
// UTC by `mysqlPool`), and the readiness probes of `/api/ready`.
import type { AnyEntityDef } from "@conusai-kit/data"
import { createMysqlDataSource, MYSQL_MIGRATIONS_TABLE, mysqlPool } from "@conusai-kit/data-kysely/mysql"
import { type LiveLog, mariadbLiveLog } from "@conusai-kit/live-server"
import type { EngineStore } from "../../stores"
export function mariadbStore(url: string, entities: readonly AnyEntityDef[]): EngineStore {
const pool = mysqlPool(url, { connectionLimit: 10 })
const data = createMysqlDataSource({ entities, pool })
const promise = pool.promise()
let live: LiveLog | undefined
return {
data,
probes: {
store: () => promise.query("select 1"),
applied: async () => {
const [rows] = await promise.query(`select \`name\` from \`${MYSQL_MIGRATIONS_TABLE}\``)
return new Set((rows as { name: string }[]).map((row) => row.name))
},
boot: async () => undefined,
},
// Row triggers write the log in the writer's transaction; the stream polls it (TS6.2).
liveLog: () => {
live ??= mariadbLiveLog({ pool: promise })
return live
},
}
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The web shell's MongoDB store over MONGODB_URL. The driver connects on first use, so the process
// builds synchronously; `boot` makes the replica-set check of AT-D15 before a request is served, and
// app.ts stops the process when it fails.
import type { AnyEntityDef } from "@conusai-kit/data"
import { connectMongoDataSource, createMongoDataSource, MONGO_MIGRATIONS_COLLECTION } from "@conusai-kit/data-mongo"
import { type LiveLog, type MongoClientLike, mongodbLiveLog } from "@conusai-kit/live-server"
import { MongoClient } from "mongodb"
import type { EngineStore } from "../../stores"
export function mongodbStore(
url: string,
entities: readonly AnyEntityDef[],
collections: ReadonlyArray<{ readonly entity: string; readonly table: string }> = [],
): EngineStore {
const client = new MongoClient(url)
let live: LiveLog | undefined
const data = createMongoDataSource({ entities, client })
return {
data,
probes: {
store: () => client.db().command({ ping: 1 }),
applied: async () => {
const rows = await client
.db()
.collection<{ _id: string }>(MONGO_MIGRATIONS_COLLECTION)
.find({}, { projection: { _id: 1 } })
.toArray()
return new Set(rows.map((row) => String(row._id)))
},
boot: async () => {
await connectMongoDataSource(client, { entities })
},
},
// The replica set's change stream on the live collections (TS6.2); opened on the first stream.
liveLog: () => {
live ??= mongodbLiveLog({ client: client as unknown as MongoClientLike, collections })
return live
},
}
}
+67
View File
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The stores of the mariadb and mongodb profiles (spec §5.1; profiles/<name>/README.md), built once
// per web process beside the Postgres pair app.ts builds. One login, no row-level security to bind
// (AT-D20 is Postgres-only): the repository predicate is the tenant wall, which the adapters' shared
// contract suites prove. The queue is BullMQ on the linked Valkey, and a request only enqueues.
//
// `kit template:prune --profile` keeps this folder for mariadb and mongodb (and the chosen engine's
// folder); postgres and convex remove it with every `kit:profile-dialect` line.
import type { AppEnv } from "@app/config"
import type { AnyEntityDef, JobQueue } from "@conusai-kit/data"
import { createBullmqJobQueue } from "@conusai-kit/jobs-bullmq"
import { Redis } from "ioredis"
import type { EngineStore } from "../stores"
import { mariadbStore } from "./mariadb/index" // kit:profile-mariadb
import { mongodbStore } from "./mongodb/index" // kit:profile-mongodb
export interface DialectStores extends EngineStore {
/** The queue a request enqueues on; it connects on the first enqueue. */
queue(): JobQueue
close(): Promise<void>
}
/**
* The BullMQ key prefix of this app: `kit-jobs-<slug>`, from the project the platform injects, so
* two apps sharing one Valkey service never meet (the worker's `queueSlug` is the same function).
*/
export function queueSlug(env: Pick<AppEnv, "deployment">): string {
const id = env.deployment.CONUSAI_PROJECT_ID.toLowerCase().replace(/[^a-z0-9-]+/g, "-")
return `p-${id}`.replace(/-+$/, "")
}
type Collections = ReadonlyArray<{ readonly entity: string; readonly table: string }>
function engineStore(env: AppEnv, entities: readonly AnyEntityDef[], collections: Collections): EngineStore | null {
if (env.mariadb) return mariadbStore(env.mariadb.url, entities) // kit:profile-mariadb
if (env.mongodb) return mongodbStore(env.mongodb.url, entities, collections) // kit:profile-mongodb
return null
}
/** The stores of the mariadb or mongodb profile, or null on any other. */
export function dialectStores(
env: AppEnv,
entities: readonly AnyEntityDef[],
collections: Collections = [],
): DialectStores | null {
const store = engineStore(env, entities, collections)
if (!store) return null
const redisUrl = env.redis?.url
if (!redisUrl) throw new Error("the mariadb and mongodb profiles need REDIS_URL for their job queue")
let connection: Redis | undefined
let queue: ReturnType<typeof createBullmqJobQueue> | undefined
return {
...store,
queue: () => {
// BullMQ needs `maxRetriesPerRequest: null` on the connection it is given.
connection ??= new Redis(redisUrl, { maxRetriesPerRequest: null })
queue ??= createBullmqJobQueue({ connection, slug: queueSlug(env) })
return queue
},
close: async () => {
await queue?.close().catch(() => undefined)
await connection?.quit().catch(() => undefined)
await store.data.close()
},
}
}
+78
View File
@@ -0,0 +1,78 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a loader does when a procedure it called says no, so every page says the same thing (spec §3.3).
//
// 401 the visitor is not signed in: a 302 to the sign-in page, which brings them back here
// 404 a stranger, a missing organization or a record of another tenant: the not-found page. The
// kernel gives these one answer on purpose, and the page keeps it.
// 403 a member whose permissions do not allow it: the forbidden page, shown only to a member
//
// Anything else is a fault and goes on to the error boundary. Each status is set before anything streams:
// a loader runs before the render starts (F10).
//
// Start has no `forbidden()` (M5). The forbidden page is the not-found page's component with
// `data.forbidden`, and its 403 is asked for with `PAGE_STATUS_HEADER`, which src/server.ts turns into the
// response's status and removes, so the router's own 404 for a not-found does not win.
import { AppError } from "@conusai-kit/core"
import { notFound, redirect } from "@tanstack/react-router"
import { setResponseHeader, setResponseStatus } from "@tanstack/react-start/server"
/** A response header a loader sets to fix the page's status; src/server.ts applies it and strips it. */
export const PAGE_STATUS_HEADER = "x-conusai-page-status"
/** What the not-found component is handed when the page is the forbidden one. */
export interface StatusPageData {
readonly forbidden?: boolean
}
/** The sign-in page, with where to come back to and, when there is one, why the visitor is there. */
export function signInHref(returnTo: string, error?: string): string {
const query = new URLSearchParams()
if (returnTo !== "/") query.set("return_to", returnTo)
if (error) query.set("error", error)
const text = query.toString()
return `/auth/sign-in${text ? `?${text}` : ""}`
}
function pageStatus(status: number): void {
setResponseStatus(status)
setResponseHeader(PAGE_STATUS_HEADER, String(status))
}
/** The not-found page, answered 404. */
export function pageNotFound(): never {
pageStatus(404)
throw notFound()
}
/** The forbidden page, answered 403: Next's `forbidden()`, which Start does not have. */
export function pageForbidden(): never {
pageStatus(403)
throw notFound({ data: { forbidden: true } satisfies StatusPageData })
}
/** To the sign-in page and back, answered 302. */
export function toSignIn(path: string): never {
throw redirect({ href: signInHref(path), statusCode: 302 })
}
/** To another page of this app, answered 302. */
export function toPage(href: string): never {
throw redirect({ href, statusCode: 302 })
}
/** Runs a read and turns a refusal into the page that belongs to it. */
export async function orFallback<T>(path: string, run: () => Promise<T>): Promise<T> {
let failure: unknown
try {
return await run()
} catch (error) {
failure = error
}
if (failure instanceof AppError) {
if (failure.status === 404) pageNotFound()
if (failure.status === 403) pageForbidden()
if (failure.status === 401) toSignIn(path)
}
throw failure
}
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Who in this process hears that an organization's grants changed, on a profile other than Postgres,
// whose host keeps its own (`createPostgresHost`). It sits below live.ts and request.ts: the live server
// authorizes its streams with a request's caller, and a request's tenancy writes announce to the live
// server, so neither may import the other (dependency-cruiser's no-circular).
import { createLiveGrants } from "@conusai-kit/host/postgres"
export const liveGrants = createLiveGrants()
/** The id of an organization this process streams, by slug. */
export const streamedOrgId = liveGrants.streamedOrgId
/** The binding of kit-tenancy's GRANTS_CHANGED port (app.ts). */
export const liveGrantsChanged = liveGrants.port
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// `POST /api/v1/orgs/{org}/live` (umbrella TS6.2). On Postgres it is `createPostgresHost`'s. The mariadb
// and mongodb profiles serve the same mount over their store's live log (dialect/), and the Convex
// profile over the deployment's log functions, with the deployment deciding the grants (convex-live.ts).
import { PROFILE } from "@app/config"
import {
createHostLive,
type HostLive,
type LiveAuthorize, // kit:profile-convex
liveOrgSlugOf, // kit:profile-convex
liveSessionOf, // kit:profile-convex
liveSignedIn, // kit:profile-convex
} from "@conusai-kit/host/postgres"
import { convexLiveLog, problems } from "@conusai-kit/live-server" // kit:profile-convex
import { postgresHost, webRuntime } from "./app"
import { convexAdminClient } from "./convex-caller" // kit:profile-convex
import { convexLiveAuthorize, deploymentLiveSource, type LiveQueryClient } from "./convex-live" // kit:profile-convex
import { liveGrants } from "./live-grants"
import {
type RequestContext, // kit:profile-convex
requestContext,
} from "./request"
let mount: HostLive | undefined
// kit:profile-convex:begin
/** The deployment decides a Convex stream's grants; this process only says who is asking. */
function convexAuthorize(convex: NonNullable<ReturnType<typeof webRuntime>["convex"]>): LiveAuthorize {
const { observe } = webRuntime()
return convexLiveAuthorize({
caller: async (request) => {
const { principal, convexIdentity } = await liveSignedIn<RequestContext>(requestContext, request)
if (!convexIdentity) throw problems.unauthenticated()
return { userId: principal.actor.userId, session: liveSessionOf(principal), identity: convexIdentity }
},
clientFor: (identity) => convexAdminClient(convex, identity) as unknown as LiveQueryClient,
orgSlugOf: liveOrgSlugOf,
warn: (message) => observe.log.warn(message),
})
}
// kit:profile-convex:end
/** The live mount of a profile other than Postgres, built on the first stream. */
function liveMount(): HostLive {
mount ??= createHostLive({
runtime: webRuntime,
requestContext,
grants: liveGrants,
log: (_onError) => {
// kit:profile-convex:begin
const { convex } = webRuntime()
if (convex) return convexLiveLog({ source: deploymentLiveSource(convex), onError: _onError })
// kit:profile-convex:end
return webRuntime().liveLog?.()
},
authorize: () => {
// kit:profile-convex:begin
const { convex } = webRuntime()
if (convex) return convexAuthorize(convex)
// kit:profile-convex:end
return (mount as HostLive).authorize
},
})
return mount
}
/** Answers one `POST …/live`; a profile with no live log answers 501. */
export function serveLive(request: Request): Promise<Response> {
return PROFILE === "postgres" ? postgresHost().live(request) : liveMount().serve(request)
}
+48
View File
@@ -0,0 +1,48 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Which nav items a flag hides (core-14). A manifest's nav item may name a feature flag some module declares;
// the sidebar and a project's tabs list it only while that flag is on for the scope the person is in, so a
// switched-off page is not a link to its 404. The page itself still answers through its module's own check.
import { type Container, token } from "@conusai-kit/core"
import { webRuntime } from "./app"
/** The scope a flag is read for: the workspace's sidebar, or a project's tabs. */
export interface NavFlagScope {
readonly org: string
readonly workspace: string
readonly project?: string
}
/** What a flag service answers for a scope. */
export interface NavFlags {
enabled(flag: string, scope: NavFlagScope): boolean | Promise<boolean>
}
/**
* Bound by an app that evaluates its modules' flags (the platform's flag service, through
* `@conusai-sdk/node-sdk/flags`): bind the same service here as in each module's flag port, so the nav and the
* module's procedures agree. With nothing bound, a flag is its manifest default, as in a module's port.
*/
export const NAV_FLAGS = token<NavFlags>("app.nav.flags")
/** The flags the items name that are on for the scope. */
export async function flagsOn(
container: Container,
items: readonly { readonly flag?: string }[],
scope: NavFlagScope,
): Promise<readonly string[]> {
const named = [...new Set(items.flatMap((item) => (item.flag === undefined ? [] : [item.flag])))]
if (named.length === 0) return []
const declared = webRuntime().registry.flags
const flags: NavFlags = container.has(NAV_FLAGS)
? container.resolve(NAV_FLAGS)
: { enabled: (flag) => declared[flag]?.default === true }
const on = await Promise.all(named.map(async (flag) => [flag, (await flags.enabled(flag, scope)) === true] as const))
return on.filter(([, enabled]) => enabled).map(([flag]) => flag)
}
/** The items whose flag, if they name one, is on. */
export const flagged = <Item extends { readonly flag?: string }>(
items: readonly Item[],
on: readonly string[],
): Item[] => items.filter((item) => item.flag === undefined || on.includes(item.flag))
+990
View File
@@ -0,0 +1,990 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The loaders of the pages under an organization (F10): the organization's shell, its workspaces and
// projects, the documents pages, its brand and settings, the audit viewers and kit-tenancy's access pages.
// Each returns plain data with its labels already translated; `src/routes/$org*` render it. Server-only: a
// route reaches this file through a server function whose handler imports it.
//
// Every read goes through `orFallback`: the loaders of one page run side by side, so each answers a refusal
// with the same status, whichever of them finishes first (spec §3.3).
import { PROFILE } from "@app/config"
// Each optional module's shell code asks for its own selection through its own name, so pruning one keeps the other's.
import {
appModules,
moduleSelected as documentsSelected, // kit:documents
moduleSelected as knowledgeSelected, // kit:knowledge
} from "@app/modules"
import { type BrandView, brandGet, logoUrl, tenantLogo, tenantStyle } from "@app/modules/branding"
import type { BrandFormLabels } from "@app/modules/branding/ui"
import { brandFormLabels } from "@app/modules/branding/ui"
import * as documentsApi from "@app/modules/documents" // kit:documents
import * as documentsUi from "@app/modules/documents/ui" // kit:documents
import { settingsGet } from "@app/modules/knowledge" // kit:knowledge
import type { SettingsSectionLabels } from "@app/modules/knowledge/ui" // kit:knowledge
import { tokens } from "@conusai-kit/brand"
import { isOwnedBy } from "@conusai-kit/host/http"
import { brandingSchema, meGet, orgGet, projectGet, projectList, workspaceList } from "@conusai-kit/tenancy"
import {
type AdminPageContext,
accessAuditPage,
collectAccessEvents,
invitationsPage,
membersPage,
requireSection,
rolePage,
rolesPage,
} from "@conusai-kit/tenancy/ui"
import type { ClientHostContext } from "@conusai-kit/ui"
import type { PermissionKey } from "@conusai-kit/ui/data/server"
import type { SwitcherItem } from "@conusai-kit/ui/frame"
import type { SettingsEntry } from "@conusai-kit/ui/patterns"
import { ConusAIClient } from "@conusai-sdk/node-sdk"
import { getRequest } from "@tanstack/react-start/server"
import { DEFAULT_LOCALE_SETTING, type OrganizationFields, type OrganizationLabels } from "../ui/organization-language"
import type { Field } from "../ui/procedure-form"
import { webRuntime } from "./app"
import { orFallback, pageNotFound, toPage, toSignIn } from "./guard"
import { flagged, flagsOn } from "./nav-flags"
import { holds, type NavLink, type PageContext, pageContext, permissionsIn, requirePermission } from "./page"
import { lastWorkspaceOf } from "./preferences"
import { PROJECT_ENTRIES, PROJECT_MODULE_KEYS } from "./project-entries"
export interface OrgParams {
readonly org: string
}
export interface WorkspaceParams extends OrgParams {
readonly workspace: string
}
export interface ProjectParams extends WorkspaceParams {
readonly project: string
}
/** A project's page, with its label translated for the project home. */
export interface ProjectEntryLink {
readonly href: string
readonly label: string
}
const projectLinks = async (
page: PageContext,
permissions: readonly string[],
scope: ProjectParams,
): Promise<readonly ProjectEntryLink[]> =>
flagged(PROJECT_ENTRIES, await flagsOn(page.container, PROJECT_ENTRIES, scope))
.filter((entry) => !entry.permission || permissions.includes(entry.permission))
.map((entry) => ({
href: entry.href,
label: page.i18n.message(entry.label),
}))
const adminContext = (page: PageContext, org: string): AdminPageContext => ({
org,
caller: page.caller,
i18n: page.i18n,
request: getRequest(),
})
// ---------------------------------------------------------------- the organization's shell
export interface TenantLogoData {
readonly src: string
readonly srcSet: string
}
export interface OrgLayoutData {
/** The organization's name as the switcher shows it. */
readonly name: string
readonly brandMark?: string
/** The organization's colours, as custom properties on the <body> of everything it sees. */
readonly style: Readonly<Record<string, string>>
readonly logoDark?: TenantLogoData
readonly logoLight?: TenantLogoData
/** The organization's home: in the sidebar on a page with no workspace. */
readonly home: NavLink
readonly settingsLabel: string
/**
* The workspace an organization's own page (Settings) is shown in: the one the person was last in, else the
* default, else the first. Its sidebar and the breadcrumb's first step, as on a page of the workspace itself.
*/
readonly workspace?: WorkspaceLayoutData
readonly permissions: readonly string[]
/** Every organization this person belongs to: the switcher offers them when there is more than one. */
readonly organizations: SwitcherItem[]
readonly workspaces: SwitcherItem[]
/** Where someone who may create a workspace goes to: the switcher offers it. */
readonly workspaceCreateHref?: string
/** Where someone who may found an organization goes to: the switcher offers it beside the organizations. */
readonly organizationCreateHref?: string
readonly person: { readonly name: string; readonly detail: string }
readonly accountHref: string
/** Settings opens on the first of its tabs the person has: the organization's, then the workspace's, then theirs. */
readonly settingsHref: string
/**
* The settings pages' layout: the organization's and the person's sections, each with the permission that shows
* it. A workspace's own sections (a module's page placed in Settings) come with the workspace (`settings`).
*/
readonly settings: {
readonly title: string
readonly label: string
readonly sections: readonly SettingsEntry[]
}
readonly client: ClientHostContext
}
/** One stored logo, at 1x and at 2x for a high-density screen. */
const logoOf = (path: string): TenantLogoData => ({
src: logoUrl(path, "1x"),
srcSet: `${logoUrl(path, "1x")} 1x, ${logoUrl(path, "2x")} 2x`,
})
const accountHrefOf = (org: string): string => `/${org}/settings/account`
/** Where an organization is founded (`routes/onboarding.tsx`). */
const ONBOARDING_PATH = "/onboarding"
const enabledModules = () =>
appModules.filter((module) => PROFILE !== "convex" || ["tenancy", "auth", "files", "notes"].includes(module.id))
/**
* The organization's pages the shell has a route for, by the `href` of a module's `org` item, in the order of their
* Settings tabs: the organization itself first, as on stage, then its people, its brand and its records.
*/
const ORGANIZATION_PAGES: Readonly<Record<string, Pick<SettingsEntry, "group"> & { label?: string; open?: boolean }>> =
{
settings: { group: "organization", label: "shell.general" },
members: { group: "organization" },
roles: { group: "organization" },
invitations: { group: "organization" },
// Every member reads the brand, and `permission` is who changes it.
branding: { group: "organization", open: true },
"access-audit": { group: "organization" },
workspaces: { group: "workspace" },
}
const PAGE_ORDER = Object.keys(ORGANIZATION_PAGES)
/** The tabs' order in `SettingsLayout`: the first the person may open is where Settings opens. */
const SETTINGS_ORDER: readonly SettingsEntry["group"][] = ["organization", "workspace", "personal"]
function firstSection(sections: readonly SettingsEntry[], permissions: readonly string[]): string | undefined {
return SETTINGS_ORDER.flatMap((group) => sections.filter((entry) => entry.group === group)).find(
(entry) => entry.permission === undefined || permissions.includes(entry.permission),
)?.href
}
/**
* The left of the frame holds what a person works in; an organization's own pages are Settings, one entry at
* the sidebar's foot, a row of tabs under the permission each manifest names, the audit viewer among them.
*/
function settingsOf(org: string, i18n: PageContext["i18n"]): OrgLayoutData["settings"] {
const sections = enabledModules().flatMap((module) =>
(module.ui?.nav ?? []).flatMap((item) => {
const page = item.scope === "org" && Object.hasOwn(ORGANIZATION_PAGES, item.href) && ORGANIZATION_PAGES[item.href]
if (!page) return []
return [
{
group: page.group,
label: i18n.message(page.label ?? item.label),
href: `/${org}/${item.href}`,
...(item.permission === undefined || page.open ? {} : { permission: item.permission }),
},
]
}),
)
sections.sort(
(a, b) => PAGE_ORDER.indexOf(a.href.split("/")[2] ?? "") - PAGE_ORDER.indexOf(b.href.split("/")[2] ?? ""),
)
return {
title: i18n.message("app.settings"),
label: i18n.message("shell.sections"),
sections: [
...sections,
{
group: "organization",
label: i18n.message("app.audit"),
href: `/${org}/audit`,
permission: "tenancy.audit.read",
},
{ group: "personal", label: i18n.message("account.link"), href: accountHrefOf(org) },
],
}
}
/** What `organizations.branding` holds, or nothing: a value that does not parse is not applied, whatever wrote it. */
function brandOf(raw: unknown): BrandView {
const parsed = brandingSchema.safeParse(raw)
const branding = parsed.success ? parsed.data : {}
return {
displayName: branding.displayName ?? null,
primary: branding.primary ?? null,
accent: branding.accent ?? null,
logoLight: branding.logoLight ?? null,
logoDark: branding.logoDark ?? null,
}
}
/**
* The organization route's loader. The scope is resolved on the server, once, by calling an
* organization-scoped procedure: a stranger and a missing organization both end at the not-found page
* (guard.ts), so the route cannot be used to ask which organizations exist (spec §3.3, §6.4). It is also where
* the organization's own brand is read (spec §10.3).
*
* There is no share image of the organization in the page's head: `/{org}/brand/og` answers 404 to anyone who
* is not a signed-in member, which is every link-preview crawler. The root route's head stands: the app's own
* public share image.
*/
export async function orgLayoutData({ org }: OrgParams): Promise<OrgLayoutData> {
const { caller, i18n, signedIn, path, mayCreateOrganization } = await pageContext()
if (!signedIn) toSignIn(path)
const [organization, me, held, { workspaces }] = await orFallback(path, () =>
Promise.all([
caller.call(orgGet, { org }),
caller.call(meGet, undefined),
permissionsIn({ org }),
caller.call(workspaceList, { org }),
]),
)
// The permissions this person holds in the organization -- a built-in role's or a custom one's
// (`tenancy.me.permissions`) -- so the shell drops what would only be refused. Reflection only: each
// page and procedure checks again.
const permissions = held.permissions
const brand = brandOf(organization.branding)
// A theme shows its own logo, or the other one when only one is uploaded (`tenantLogo`, the branding contract).
const lightLogo = tenantLogo(brand, "light")
const darkLogo = tenantLogo(brand, "dark")
const brandMark = brand.primary ?? brand.accent ?? undefined
const remembered = lastWorkspaceOf(getRequest().headers.get("cookie"), org)
const current =
workspaces.find((entry) => entry.slug === remembered) ??
workspaces.find((entry) => entry.isDefault) ??
workspaces[0]
const workspace = current ? await orFallback(path, () => workspaceLayout(org, current, i18n)) : undefined
const settings = settingsOf(org, i18n)
return {
name: brand.displayName ?? organization.name,
...(brandMark ? { brandMark } : {}),
style: tenantStyle(brand).style,
...(darkLogo ? { logoDark: logoOf(darkLogo.path) } : {}),
...(lightLogo ? { logoLight: logoOf(lightLogo.path) } : {}),
settingsLabel: i18n.message("workspace.organizationSettings"),
home: { scope: "org", label: i18n.message("app.workspaces"), href: `/${org}`, icon: "folder" },
...(workspace ? { workspace } : {}),
permissions,
organizations: me.organizations.map((entry) => ({ id: entry.slug, label: entry.name })),
workspaces: workspaces.map((entry) => ({ id: entry.slug, label: entry.name })),
// From the switcher, as on stage; a one-organization deployment (APP_SINGLE_ORGANIZATION) can have more than
// one workspace too. Founding an organization is `pageContext`'s rule, which that deployment closes once it has one.
...(permissions.includes("tenancy.workspace.manage") ? { workspaceCreateHref: `/${org}/workspaces` } : {}),
...(mayCreateOrganization ? { organizationCreateHref: ONBOARDING_PATH } : {}),
person: { name: me.displayName, detail: me.email },
accountHref: accountHrefOf(org),
settingsHref:
firstSection([...settings.sections, ...(workspace?.settings ?? [])], permissions) ?? accountHrefOf(org),
settings,
client: { org, locale: i18n.locale, user: me.userId },
}
}
// ---------------------------------------------------------------- the organization's home
export interface OrgHomeData {
readonly title: string
readonly description: string
readonly createWorkspace?: string
readonly none: string
readonly name: string
readonly projects: string
readonly workspaces: readonly {
readonly slug: string
readonly name: string
readonly projectCount: string
}[]
}
export async function orgHomeData({ org }: OrgParams): Promise<OrgHomeData> {
const page = await pageContext()
const { caller, i18n, path } = page
const rows = await orFallback(path, async () => {
const { workspaces } = await caller.call(workspaceList, { org })
const [only] = workspaces
if (only && workspaces.length === 1 && webRuntime().singleOrganization) {
toPage(`/${encodeURIComponent(org)}/${encodeURIComponent(only.slug)}`)
}
return Promise.all(
workspaces.map(async (workspace) => ({
workspace,
projects: (await caller.call(projectList, { org, workspace: workspace.slug })).projects,
})),
)
})
return {
title: i18n.message("app.workspaces"),
description: i18n.message("app.chooseWorkspace"),
...((await orFallback(path, () => holds(page, { org }, "tenancy.workspace.manage")))
? { createWorkspace: i18n.message("workspace.create") }
: {}),
none: i18n.message("workspace.none"),
name: i18n.message("app.name"),
projects: i18n.message("app.projects"),
workspaces: rows.map(({ workspace, projects }) => ({
slug: workspace.slug,
name: workspace.name,
projectCount: i18n.message("workspace.projectCount", { count: projects.length }),
})),
}
}
// ---------------------------------------------------------------- workspaces
export interface WorkspacesData {
readonly title: string
readonly none: string
readonly name: string
readonly visibility: string
readonly rows: readonly {
readonly slug: string
readonly name: string
readonly isDefault?: string
readonly visibility: string
}[]
/** The form, for whoever holds `tenancy.workspace.manage`; the kernel refuses everybody else regardless. */
readonly create?: {
readonly title: string
readonly submit: string
readonly fields: Field[]
}
}
export async function workspacesData({ org }: OrgParams): Promise<WorkspacesData> {
const page = await pageContext()
const { caller, i18n, path } = page
const { workspaces } = await orFallback(path, () => caller.call(workspaceList, { org }))
const mayCreate = await orFallback(path, () => holds(page, { org }, "tenancy.workspace.manage"))
const visibilityOrg = i18n.message("workspace.visibilityOrg")
const visibilityRestricted = i18n.message("workspace.visibilityRestricted")
return {
title: i18n.message("workspace.list"),
none: i18n.message("workspace.none"),
name: i18n.message("app.name"),
visibility: i18n.message("workspace.visibility"),
rows: workspaces.map((workspace) => ({
slug: workspace.slug,
name: workspace.name,
...(workspace.isDefault ? { isDefault: i18n.message("workspace.default") } : {}),
visibility: workspace.visibility === "org" ? visibilityOrg : visibilityRestricted,
})),
...(mayCreate
? {
create: {
title: i18n.message("workspace.create"),
submit: i18n.message("app.create"),
fields: [
{ name: "name", label: i18n.message("app.name"), required: true },
{ name: "slug", label: i18n.message("app.slug"), required: true },
{
name: "visibility",
label: i18n.message("workspace.visibility"),
required: true,
options: [
{ value: "org", label: visibilityOrg },
{ value: "restricted", label: visibilityRestricted },
],
},
],
},
}
: {}),
}
}
/**
* The workspace route's loader: the workspace has to be one this person can see, and the page says 404 before
* it starts if it is not (spec §3.3). A restricted workspace the person is not in is not in
* `tenancy.workspace.list`, so it is the same not-found as one that does not exist.
*/
export async function requireWorkspace({ org, workspace }: WorkspaceParams): Promise<WorkspaceLayoutData> {
const { caller, i18n, path } = await pageContext()
const { workspaces } = await orFallback(path, () => caller.call(workspaceList, { org }))
const found = workspaces.find((entry) => entry.slug === workspace)
if (!found) pageNotFound()
const layout = await orFallback(path, () => workspaceLayout(org, found, i18n))
const base = `/${org}/${workspace}`
const projects = layout.permissions.includes("tenancy.project.read")
? await orFallback(path, () => caller.call(projectList, { org, workspace, sort: "name" }))
: { projects: [] }
return {
...layout,
projectNavigation: {
groupLabel: i18n.message("workspace.projectGroup"),
pickerLabel: i18n.message("workspace.switchProject"),
chooseLabel: i18n.message("workspace.chooseProject"),
projects: projects.projects.map(({ slug, name }) => ({ id: slug, label: name, href: `${base}/${slug}` })),
...(layout.permissions.includes("tenancy.project.manage")
? { create: { label: i18n.message("workspace.createProject"), href: `${base}/modules?create=true` } }
: {}),
},
}
}
/** A workspace this person can see, as the frame shows it: its name, their permissions in it, and its sidebar. */
async function workspaceLayout(
org: string,
found: { readonly slug: string; readonly name: string },
i18n: PageContext["i18n"],
): Promise<WorkspaceLayoutData> {
const workspace = found.slug
const { permissions } = await permissionsIn({ org, workspace })
// The registry's nav is already in `order` within the scope (core-5); a flagged page is listed while its flag is on (core-14).
const inWorkspace = webRuntime().registry.nav.filter((item) => item.scope === "workspace")
const on = await flagsOn((await pageContext()).container, inWorkspace, { org, workspace })
const pages = flagged(inWorkspace, on).filter(
(item) => item.permission === undefined || permissions.includes(item.permission as PermissionKey),
)
const groupLabel = i18n.message("workspace.group")
const base = `/${org}/${workspace}`
const hrefOf = (item: { readonly href: string }) => `/${org}/${workspace}/modules/${item.href}`
return {
workspace: { id: found.slug, label: found.name },
permissions,
nav: [
{
scope: "workspace",
label: i18n.message("workspace.overview"),
href: base,
icon: "chart",
group: groupLabel,
end: true,
},
...(permissions.includes("tenancy.project.read")
? [
{
scope: "workspace" as const,
label: i18n.message("shell.projects"),
href: `${base}/modules`,
icon: "folder",
group: groupLabel,
end: true,
},
]
: []),
...pages
.filter((item) => item.placement !== "settings")
.map(({ icon, ...item }) => ({
scope: "workspace" as const,
label: i18n.message(item.label),
href: hrefOf(item),
...(icon ? { icon } : {}),
group: groupLabel,
})),
],
settings: pages
.filter((item) => item.placement === "settings")
.map((item) => ({ group: "workspace" as const, label: i18n.message(item.label), href: hrefOf(item) })),
}
}
/** The workspace as the frame shows it, and what its sidebar lists: Projects, then the modules' pages. */
export interface WorkspaceLayoutData {
readonly workspace: SwitcherItem
readonly permissions: readonly string[]
readonly nav: readonly NavLink[]
readonly projectNavigation?: import("@conusai-kit/ui/frame").ProjectNavigation
/** The modules' pages placed in Settings (`placement: "settings"`): tabs there, already filtered by permission. */
readonly settings: readonly SettingsEntry[]
}
export interface WorkspaceHomeData {
readonly locale: string
readonly name: string
readonly title: string
readonly projectsHeading: string
readonly description: string
readonly none: string
readonly columnName: string
readonly columnModule: string
readonly projects: readonly { readonly slug: string; readonly name: string; readonly moduleKey: string }[]
/** The form, for whoever holds `tenancy.project.manage`. */
readonly create?: {
readonly title: string
readonly submit: string
readonly fields: Field[]
}
}
export async function workspaceHomeData({ org, workspace }: WorkspaceParams): Promise<WorkspaceHomeData> {
const page = await pageContext()
const { caller, i18n, path } = page
const { permissions } = await orFallback(path, () => permissionsIn({ org, workspace }))
const { name, projects } = await orFallback(path, async () => {
const [{ projects }, { workspaces }] = await Promise.all([
permissions.includes("tenancy.project.read") ? caller.call(projectList, { org, workspace }) : { projects: [] },
caller.call(workspaceList, { org }),
])
// `projectList` answers for the workspace's scope; the name is the list's own, so the page does
// not need a procedure of its own to say it.
return { projects, name: workspaces.find((entry) => entry.slug === workspace)?.name ?? workspace }
})
const mayCreate = permissions.includes("tenancy.project.manage")
return {
name,
locale: i18n.locale,
title: i18n.message("workspace.title", { name }),
projectsHeading: i18n.message("workspace.projects"),
description: i18n.message("workspace.nextAction"),
none: i18n.message("workspace.noProjects"),
columnName: i18n.message("app.name"),
columnModule: i18n.message("workspace.module"),
projects: projects.map(({ slug, name, moduleKey }) => ({ slug, name, moduleKey })),
...(mayCreate
? {
create: {
title: i18n.message("workspace.createProject"),
submit: i18n.message("app.create"),
fields: [
{ name: "name", label: i18n.message("app.name"), required: true },
{ name: "slug", label: i18n.message("app.slug"), required: true },
{
name: "moduleKey",
label: i18n.message("workspace.module"),
required: true,
options: PROJECT_MODULE_KEYS.map((key) => ({ value: key, label: key })),
},
],
},
}
: {}),
}
}
// ---------------------------------------------------------------- a project
export interface ProjectHomeData {
readonly workspaceName: string
readonly name: string
readonly title: string
readonly module: string
readonly archived?: string
readonly work: string
readonly noSections: string
readonly entries: readonly ProjectEntryLink[]
}
export async function projectHomeData({ org, workspace, project }: ProjectParams): Promise<ProjectHomeData> {
const page = await pageContext()
const { caller, i18n, path } = page
const { found, workspaceName, permissions } = await orFallback(path, async () => {
const [found, { workspaces }, { permissions }] = await Promise.all([
caller.call(projectGet, { org, workspace, project }),
caller.call(workspaceList, { org }),
permissionsIn({ org, workspace, project }),
])
return {
found,
permissions,
workspaceName: workspaces.find((entry) => entry.slug === workspace)?.name ?? workspace,
}
})
return {
workspaceName,
name: found.name,
title: i18n.message("project.title", { name: found.name }),
module: found.description ?? i18n.message("project.module", { module: found.moduleKey }),
...(found.archived ? { archived: i18n.message("project.archived") } : {}),
work: i18n.message("project.work"),
noSections: i18n.message("project.noSections"),
entries: await projectLinks(page, permissions, { org, workspace, project }),
}
}
type Translate = (key: string, values?: Record<string, string | number>) => string
// kit:documents:begin
// ---------------------------------------------------------------- documents
export interface DocumentsData {
readonly title: string
readonly intro: string
readonly accept: readonly string[]
readonly upload: documentsUi.UploadLabels
readonly list: documentsUi.DocumentListLabels
/** The live list's seed. */
readonly documents: Awaited<ReturnType<typeof readDocuments>>["documents"]
/** The locale the dates are written in: the list is live, so the browser formats each row's. */
readonly locale: string
}
const readDocuments = (page: PageContext, slugs: ProjectParams) => page.caller.call(documentsApi.documentList, slugs)
/** The project's documents: every read a procedure through the same caller the API uses (spec §6.2). */
export async function documentsData(slugs: ProjectParams): Promise<DocumentsData> {
if (!documentsSelected("documents")) pageNotFound()
if (PROFILE === "convex") pageNotFound()
const page = await pageContext()
const { i18n, path } = page
const { documents } = await orFallback(path, () => readDocuments(page, slugs))
const t: Translate = (key, values) => i18n.message(`documents.${key}`, values)
return {
title: i18n.message("documents.title"),
intro: i18n.message("documents.intro"),
accept: documentsApi.READABLE_CONTENT_TYPES,
upload: documentsUi.uploadLabels(t),
list: documentsUi.listLabels(t),
documents,
locale: i18n.locale,
}
}
export interface DocumentParams extends ProjectParams {
readonly fileId: string
}
export interface DocumentData {
readonly back: string
readonly title: string
/** The live document's seed: the header and the lines are built from it wherever the page renders. */
readonly document: Awaited<ReturnType<typeof readDocument>>["document"]
readonly lines: Awaited<ReturnType<typeof readDocument>>["lines"]
readonly header: documentsUi.DocumentHeaderLabels
readonly review: documentsUi.ReviewLabels
readonly exportLabels: documentsUi.ExportLabels
readonly locale: string
}
const readDocument = (page: PageContext, input: DocumentParams) => page.caller.call(documentsApi.documentGet, input)
/** One document: a document of another organization and one that does not exist are the same page. */
export async function documentData(input: DocumentParams): Promise<DocumentData> {
if (!documentsSelected("documents")) pageNotFound()
if (PROFILE === "convex") pageNotFound()
const page = await pageContext()
const { i18n, path } = page
const { document, lines } = await orFallback(path, () => readDocument(page, input))
const t: Translate = (key, values) => i18n.message(`documents.${key}`, values)
const failed = i18n.message("app.failed")
return {
back: i18n.message("documents.review.back"),
title: i18n.message("documents.review.title", { filename: document.filename }),
document,
lines,
header: documentsUi.headerLabels(t),
review: documentsUi.reviewLabels(t, {
failed,
aiDisclosure: i18n.message("ui.aiDisclosure"),
pageWord: i18n.message("ui.page"),
}),
exportLabels: documentsUi.exportLabels(t, failed),
locale: i18n.locale,
}
}
// kit:documents:end
// ---------------------------------------------------------------- the organization's brand and settings
export interface BrandingData {
readonly title: string
readonly intro: string
readonly brand: BrandView
readonly canEdit: boolean
readonly defaultPrimary: string
readonly labels: BrandFormLabels
}
/**
* Every member sees it; an administrator changes it (`branding.brand.write`, which the kernel checks before a
* handler runs), and a member without that permission is shown the same page read-only.
*/
export async function brandingData({ org }: OrgParams): Promise<BrandingData> {
if (PROFILE === "convex") pageNotFound()
const page = await pageContext()
const { caller, i18n, path } = page
const brand = await orFallback(path, () => caller.call(brandGet, { org }))
const canEdit = await orFallback(path, () => holds(page, { org }, "branding.brand.write"))
const t: Translate = (key, values) => i18n.message(`branding.${key}`, values)
return {
title: i18n.message("branding.title"),
intro: i18n.message("branding.intro"),
brand,
canEdit,
defaultPrimary: tokens.color.dark.accent,
labels: brandFormLabels(t),
}
}
export interface OrgSettingsData {
readonly title: string
readonly slugLabel: string
readonly statusLabel: string
readonly slug: string
readonly status: string
readonly organization: OrganizationFields
readonly organizationLabels: OrganizationLabels
readonly knowledge: Awaited<ReturnType<typeof readKnowledge>> | undefined // kit:knowledge
readonly labels: SettingsSectionLabels // kit:knowledge
}
const readKnowledge = (page: PageContext, org: string) => page.caller.call(settingsGet, { org }) // kit:knowledge
/** For whoever holds `tenancy.org.update`: a member without it gets the forbidden page, a stranger the not-found page. */
export async function orgSettingsData({ org }: OrgParams): Promise<OrgSettingsData> {
const page = await pageContext()
const { caller, i18n, path } = page
await requirePermission(page, org, "tenancy.org.update")
const organization = await orFallback(path, () => caller.call(orgGet, { org }))
const { [DEFAULT_LOCALE_SETTING]: defaultLocale } = organization.settings
// kit:knowledge:begin
const knowledge = knowledgeSelected("knowledge") ? await orFallback(path, () => readKnowledge(page, org)) : undefined
// The request's i18n already carries every module's catalogue (kit-i18n merges them from the
// manifests), so the module's own keys resolve with no import of its messages here.
const t = (key: string) => i18n.message(`knowledge.${key}`)
// kit:knowledge:end
return {
title: i18n.message("shell.general"),
slugLabel: i18n.message("app.slug"),
statusLabel: i18n.message("app.status"),
slug: organization.slug,
status: organization.status,
organization: {
name: organization.name,
defaultLocale: typeof defaultLocale === "string" ? defaultLocale : i18n.locale,
version: organization.version,
},
organizationLabels: {
title: i18n.message("shell.organizationTitle"),
name: i18n.message("app.name"),
language: i18n.message("shell.defaultLanguage"),
languageHint: i18n.message("shell.defaultLanguageHint"),
languages: [
{ value: "en", label: i18n.message("shell.languageEn") },
{ value: "lt", label: i18n.message("shell.languageLt") },
],
save: i18n.message("ui.save"),
saved: i18n.message("shell.saved"),
},
// kit:knowledge:begin
knowledge,
labels: {
heading: t("settings.heading"),
state: t("settings.state"),
stateOn: t("settings.stateOn"),
stateOff: t("settings.stateOff"),
backend: t("settings.backend"),
backendPlatform: t("settings.backendPlatform"),
enable: t("settings.enable"),
enabling: t("settings.enabling"),
disable: t("settings.disable"),
disabling: t("settings.disabling"),
enabledDone: t("settings.enabledDone"),
disabledDone: t("settings.disabledDone"),
planRequired: t("settings.planRequired"),
featureDisabled: t("settings.featureDisabled"),
quotaExhausted: t("settings.quotaExhausted"),
upgrade: t("settings.upgrade"),
hint: t("settings.hint"),
failed: i18n.message("app.failed"),
},
// kit:knowledge:end
}
}
// ---------------------------------------------------------------- the audit viewer
interface AuditRow {
id: number
action: string
outcome: string
occurred_at: string
resource_type?: string | null
resource_id?: string | null
}
/** How many of this organization's events the viewer shows, and how far back it scans for them. */
const PAGE_LIMIT = 200
const MAX_PAGES = 25
const platformClient = (): ConusAIClient => {
const { env } = webRuntime()
return new ConusAIClient({
baseUrl: env.deployment.CONUSAI_API_URL,
apiKey: env.deployment.CONUSAI_API_TOKEN,
})
}
/**
* The platform's log is deployment-wide and pages newest-first with no server-side filter, so the
* organization's rows are collected by walking pages until the viewer's own limit is filled or the
* log ends -- a filter over the first page alone would show nothing once 200 newer events of other
* organizations exist.
*/
async function organizationEvents(org: { slug: string; id: string }): Promise<AuditRow[]> {
const platform = platformClient()
const rows: AuditRow[] = []
let cursor: string | null | undefined
for (let page = 0; page < MAX_PAGES && rows.length < PAGE_LIMIT; page += 1) {
const { data } = await platform.readEvents({
query: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
throwOnError: true,
})
for (const row of (data?.items ?? []) as AuditRow[]) {
if (isOwnedBy(org, row.resource_id)) rows.push(row)
}
if (!data?.has_more || !data.next_cursor) break
cursor = data.next_cursor
}
return rows
}
export interface AuditData {
readonly title: string
readonly empty: string
readonly when: string
readonly action: string
readonly outcome: string
readonly resource: string
readonly rows: readonly {
readonly id: number
readonly when: string
readonly action: string
readonly outcome: string
readonly resource: string
}[]
}
/**
* The audit viewer (spec §9.4), for whoever holds `tenancy.audit.read` (administrators, by default). It reads
* the platform's audit log for this deployment with the deployment token, which carries `audit:read`, and
* shows only the events of this organization: every write goes through one function that puts the
* organization's slug in the event's target, and `isOwnedBy` is that rule read backwards.
*/
export async function auditData({ org }: OrgParams): Promise<AuditData> {
const page = await pageContext()
const { caller, i18n, path } = page
await requirePermission(page, org, "tenancy.audit.read")
// The id is what subscriber-written events carry.
const organization = await orFallback(path, () => caller.call(orgGet, { org }))
const { observe } = webRuntime()
const rows = await organizationEvents({ slug: org, id: organization.orgId }).catch((error: unknown) => {
// The platform being unreachable is not the viewer's failure to explain to a customer; it is
// ours to know about, so it is logged, and the page says there is nothing to show.
observe.log.warn(
`audit viewer: the platform's log could not be read: ${error instanceof Error ? error.name : "failed"}`,
)
return []
})
return {
title: i18n.message("app.audit"),
empty: i18n.message("app.auditEmpty"),
when: i18n.message("app.when"),
action: i18n.message("app.action"),
outcome: i18n.message("app.outcome"),
resource: i18n.message("app.resource"),
rows: rows.map((row) => ({
id: row.id,
when: i18n.date(new Date(row.occurred_at)),
action: row.action,
outcome: row.outcome,
resource: row.resource_type ?? "",
})),
}
}
// ---------------------------------------------------------------- kit-tenancy's access pages
/**
* The check of an access section, in the route's own loader so that a member without its permission gets a
* real 403 and a stranger a real 404 (`SECTION_PERMISSION`).
*/
export async function requireAccessSection({
org,
section,
}: OrgParams & { readonly section: "members" | "roles" }): Promise<null> {
const page = await pageContext()
if (!page.signedIn) toSignIn(page.path)
await orFallback(page.path, () => requireSection(adminContext(page, org), section))
return null
}
export type MembersData = Awaited<ReturnType<typeof membersPage.load>>
export type RolesData = Awaited<ReturnType<typeof rolesPage.load>>
export type RoleData = Awaited<ReturnType<typeof rolePage.load>>
export type AccessAuditData = Awaited<ReturnType<typeof accessAuditPage.load>>
export interface MembersParams extends OrgParams {
readonly workspace?: string
readonly project?: string
}
/** The members of the organization, of a workspace or of a project: every change is a procedure call. */
export async function membersData({ org, workspace, project }: MembersParams): Promise<MembersData> {
const page = await pageContext()
const scope =
workspace === undefined
? ({ level: "org" } as const)
: project === undefined
? ({ level: "workspace", workspace } as const)
: ({ level: "project", workspace, project } as const)
return orFallback(page.path, () => membersPage.load(adminContext(page, org), scope))
}
export async function rolesData({ org }: OrgParams): Promise<RolesData> {
const page = await pageContext()
return orFallback(page.path, () => rolesPage.load(adminContext(page, org)))
}
export interface RoleParams extends OrgParams {
readonly role: string
}
export async function roleData({ org, role }: RoleParams): Promise<RoleData> {
const page = await pageContext()
return orFallback(page.path, () => rolePage.load(adminContext(page, org), role))
}
export type InvitationsData = Awaited<ReturnType<typeof invitationsPage.load>>
/**
* The organization's invitations: the invite form, the link it makes (with Copy and its expiry) and the pending
* invitations, with a new link and revoke (kit-tenancy's access pages). The members page shows the same.
*/
export async function invitationsData({ org }: OrgParams): Promise<InvitationsData> {
const page = await pageContext()
if (!page.signedIn) toSignIn(page.path)
const context = adminContext(page, org)
await orFallback(page.path, () => requireSection(context, "invitations"))
return orFallback(page.path, () => invitationsPage.load(context, { linkPath: `/invitations/${org}/` }))
}
/**
* The access audit: this organization's `tenancy.*` audit events -- roles created, changed and deleted,
* members' roles changed, invitations made and revoked -- newest first (kit-tenancy's access pages). The
* events are the ones the router records for every tenancy procedure that declares one, in the platform's
* audit log of this deployment; they are read with the deployment token, which carries `audit:read`, and kept
* to this organization by the same rule its writes follow and to the tenancy module by the action's prefix
* (`collectAccessEvents`).
*/
export async function accessAuditData({ org }: OrgParams): Promise<AccessAuditData> {
const page = await pageContext()
const { caller, path } = page
if (!page.signedIn) toSignIn(path)
const context = adminContext(page, org)
await orFallback(path, () => requireSection(context, "audit"))
// The id is what subscriber-written events carry.
const organization = await orFallback(path, () => caller.call(orgGet, { org }))
const { observe } = webRuntime()
const platform = platformClient()
const events = await collectAccessEvents(
async (cursor) => {
const { data } = await platform.readEvents({
query: cursor ? { limit: 200, cursor } : { limit: 200 },
throwOnError: true,
})
return { items: data?.items ?? [], has_more: data?.has_more ?? false, next_cursor: data?.next_cursor ?? null }
},
(resourceId) => isOwnedBy({ slug: org, id: organization.orgId }, resourceId),
).catch((error: unknown) => {
// The platform being unreachable is ours to know about, not the viewer's to explain: it is
// logged, and the page says there is nothing to show.
observe.log.warn(
`access audit: the platform's log could not be read: ${error instanceof Error ? error.name : "failed"}`,
)
return []
})
return orFallback(path, () => accessAuditPage.load(context, events))
}
+434
View File
@@ -0,0 +1,434 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a route loader reads on the server (F10): the request's caller, its language and theme, its CSRF
// token, and the shell's labels already translated. Server-only: a route reaches it through a server
// function (`createServerFn`) whose handler imports this file, so neither `pg` nor a secret reaches the
// browser's bundle.
//
// Every loader takes exactly the path a procedure takes -- the mount's own `requestContext` on the
// request's own `Request` -- so a page is authorized as the API is (spec §6.4).
//
// React's `cache` memoizes nothing outside server components (M5), so the context is kept per request in a
// WeakMap keyed by the `Request`: the root route and the page below it read the session row once.
import { appBrand, authBrand, brandTokenStyle, publicUrlOf } from "@app/config"
import { ACCOUNT_PATH } from "@app/modules/account/contract"
import { modulePages, workspacePages } from "@app/modules/pages"
import { type Actor, AppError, type Container, type TransportCaller } from "@conusai-kit/core"
import { csrfSessionId, csrfToken } from "@conusai-kit/host/http"
import { createI18n, type I18n, uiLabels } from "@conusai-kit/i18n"
import { projectGet } from "@conusai-kit/tenancy"
import {
allows,
type PermissionKey,
type PermissionScope,
type PermissionSnapshot,
readPermissions,
} from "@conusai-kit/ui/data/server"
import type { NavItem } from "@conusai-kit/ui/frame"
import { getRequest } from "@tanstack/react-start/server"
import type { ShellData } from "../ui/document"
import { appCatalogues } from "../ui/messages"
import { webRuntime } from "./app"
import { orFallback, pageForbidden, pageNotFound, toSignIn } from "./guard"
import { flagged, flagsOn } from "./nav-flags"
import { organizationIdentity, organizationThemeStyle } from "./platform-theme"
import { localeOf, panelWidthOf, sidebarOpenOf, type Theme, themeOf } from "./preferences"
import { PROJECT_ENTRIES } from "./project-entries"
import { requestContext } from "./request"
import { mayCreateOrganization, organizationExists } from "./tenancy-calls"
export interface PageContext {
readonly caller: TransportCaller
readonly container: Container
readonly signedIn: boolean
readonly actor?: Actor
/** The signed-in person, for the shell: a name to show, never an id. */
readonly user?: { readonly email: string; readonly displayName: string; readonly locale: string }
/** The organization this credential is limited to, when it is an organization API key. */
readonly pinnedOrgId?: string
/** Whether this person may found an organization here (APP_ORGANIZATION_SELF_SERVICE, the app-access role). */
readonly mayCreateOrganization: boolean
readonly csrf?: string
readonly i18n: I18n
/** The deployment's own language, for what belongs to everybody who joins later. */
readonly deploymentI18n: I18n
readonly theme: Theme
/** The path and query of this request (src/server.ts's `x-path`): where to come back to after signing in. */
readonly path: string
/** What the browser needs to start observability, when the deployment turned it on. */
readonly observe?: { readonly release: string; readonly environment: string }
readonly nonce?: string
}
/** A navigation item as a loader hands it to the frame: its label translated, its link absolute. */
export type NavLink = NavItem
const contexts = new WeakMap<Request, Promise<PageContext>>()
const permissions = new WeakMap<Request, Map<string, Promise<PermissionSnapshot>>>()
async function buildContext(request: Request): Promise<PageContext> {
const incoming = request.headers
const { caller, container, principal, user } = await requestContext(request)
const { observe, selfServiceOrganizations, singleOrganization } = webRuntime()
const locale = localeOf({
appLocale: appBrand.defaultLocale,
...(user ? { userLocale: user.locale } : {}),
cookieHeader: incoming.get("cookie"),
acceptLanguage: incoming.get("accept-language"),
})
const csrf = csrfSessionId(principal)
return {
caller,
container,
signedIn: principal !== null,
...(principal ? { actor: principal.actor } : {}),
...(user ? { user } : {}),
...(principal?.orgId === undefined ? {} : { pinnedOrgId: principal.orgId }),
mayCreateOrganization:
mayCreateOrganization(principal, selfServiceOrganizations) &&
!(singleOrganization && (await organizationExists(container))),
...(csrf ? { csrf: csrfToken(csrf) } : {}),
i18n: createI18n(locale, appCatalogues),
deploymentI18n: createI18n(appBrand.defaultLocale, appCatalogues),
theme: themeOf(incoming.get("cookie"), appBrand.defaultTheme),
path: incoming.get("x-path") ?? "/",
...(observe.config.errors
? { observe: { release: observe.config.release, environment: observe.config.environment } }
: {}),
...(incoming.get("x-nonce") ? { nonce: incoming.get("x-nonce") as string } : {}),
}
}
/** The request's page context, built once per request. `request` defaults to the one Start is serving. */
export function pageContext(request: Request = getRequest()): Promise<PageContext> {
let context = contexts.get(request)
if (!context) {
context = buildContext(request)
contexts.set(request, context)
}
return context
}
/**
* The caller's permissions in a scope, through the procedure every client asks (`tenancy.me.permissions`),
* read once per request and scope: a layout's loader and the page's share the answer, and a page hands it
* to the browser as the seed of `PermissionsProvider`, so `useCan` answers on the first render.
*
* A page that is not a procedure -- the audit viewer -- still has to answer "may this person see it", and
* this is the one way to ask: a person who is not a member gets the procedure's NotFound exactly as from any
* other read (spec §3.3). Reflection only for a control: the procedure behind it checks its permission again.
*/
export function permissionsIn(scope: PermissionScope, request: Request = getRequest()): Promise<PermissionSnapshot> {
let byScope = permissions.get(request)
if (!byScope) {
byScope = new Map()
permissions.set(request, byScope)
}
const key = JSON.stringify(scope)
let snapshot = byScope.get(key)
if (!snapshot) {
snapshot = pageContext(request).then(({ caller }) => readPermissions(caller, scope))
byScope.set(key, snapshot)
}
return snapshot
}
/** Whether the signed-in person holds `permission` in the scope (false when signed out). */
export async function holds(page: PageContext, scope: PermissionScope, permission: PermissionKey): Promise<boolean> {
if (!page.actor) return false
return allows(await permissionsIn(scope), permission)
}
/**
* Requires a permission in an organization for a page that is not a procedure. A person who is not in
* the organization gets the not-found page; a member without the permission gets the forbidden page.
*/
export async function requirePermission(page: PageContext, org: string, permission: PermissionKey): Promise<void> {
if (!page.actor) toSignIn(page.path)
const held = await orFallback(page.path, () => permissionsIn({ org }))
if (!held.permissions.includes(permission)) pageForbidden()
}
/**
* The root route's loader data: the document's facts and every label the shell renders, translated here
* (F10: labels are loader data built on the server). The brand's token style is computed here too, so the
* browser's bundle never imports the app's config.
*/
export async function shellData(request: Request = getRequest()): Promise<ShellData> {
const { i18n, signedIn, csrf, theme, observe, nonce } = await pageContext(request)
const cookies = request.headers.get("cookie")
const panelWidth = panelWidthOf(cookies)
// The deployment's organization theme (BK12): the kit's defaults when the platform has none to give.
const deployment = webRuntime().env.deployment
const [organizationTheme, cloud] = await Promise.all([
organizationThemeStyle(deployment),
organizationIdentity(deployment),
])
const message = (key: string): string => i18n.message(key)
return {
// The organization's name and logo on Cloud, else the app's own.
brand: {
name: cloud.name ?? appBrand.name,
shortName: appBrand.shortName,
logo: {
dark: cloud.logo ?? appBrand.logo.dark,
light: cloud.logo ?? appBrand.logo.light,
favicon: appBrand.logo.favicon,
},
style: brandTokenStyle(appBrand),
},
auth: authBrand(appBrand, i18n.locale),
publicUrl: publicUrlOf(),
locale: i18n.locale,
theme,
sidebarOpen: sidebarOpenOf(cookies),
...(panelWidth === undefined ? {} : { panelWidth }),
signedIn,
...(csrf ? { csrf } : {}),
...(observe ? { observe } : {}),
...(nonce ? { nonce } : {}),
...(organizationTheme ? { organizationTheme } : {}),
accountHref: ACCOUNT_PATH,
ui: uiLabels(i18n),
labels: {
signIn: message("app.signIn"),
signOut: message("app.signOut"),
account: message("account.link"),
failed: message("app.failed"),
language: message("shell.language"),
languageEn: message("shell.languageEn"),
languageLt: message("shell.languageLt"),
theme: message("shell.theme"),
themeDark: message("shell.themeDark"),
themeLight: message("shell.themeLight"),
themeSystem: message("shell.themeSystem"),
switchToLight: message("shell.switchToLight"),
switchToDark: message("shell.switchToDark"),
loading: message("boundary.loading"),
notFoundTitle: message("boundary.notFoundTitle"),
notFoundBody: message("boundary.notFoundBody"),
forbiddenTitle: message("boundary.forbiddenTitle"),
forbiddenBody: message("boundary.forbiddenBody"),
},
help: {
title: message("ui.help"),
sections: message("shell.helpSections"),
guides: message("shell.guides"),
support: message("shell.support"),
},
privacy: {
title: message("consent.title"),
body: message("consent.body"),
replay: message("consent.replay"),
accept: message("consent.accept"),
reject: message("consent.reject"),
reopen: message("consent.reopen"),
gpc: message("consent.gpc"),
},
boundary: {
title: message("boundary.errorTitle"),
body: message("boundary.errorBody"),
code: message("boundary.errorCode"),
retry: message("boundary.retry"),
home: message("boundary.home"),
},
}
}
/** The module route's params: the project's slugs, the module's segment and the rest of the path. */
export interface ModulePageParams {
readonly org: string
readonly workspace: string
readonly project: string
readonly module: string
readonly _splat?: string
/** The query string as the location has it (`?q=a`), read by the page's `load`. */
readonly search?: string
}
/** The query string by name, as `load` receives it. A repeated name keeps its last value. */
const searchOf = (search: string | undefined): Readonly<Record<string, string>> =>
Object.fromEntries(new URLSearchParams(search ?? ""))
/** The path below a module's segment, without empty parts. */
const segmentsOf = (splat: string | undefined): readonly string[] =>
(splat ?? "").split("/").filter((segment) => segment.length > 0)
/** Runs a page's `load`, answering its 404 and 403 as those pages. */
const loaded = <Data>(run: Promise<Data>): Promise<Data> =>
run.catch((error: unknown) => {
if (error instanceof AppError && error.status === 404) pageNotFound()
if (error instanceof AppError && error.status === 403) pageForbidden()
throw error
})
/** What every page under a project is rendered with: its scope and the person's permissions there. */
export interface ProjectLayoutData {
readonly scope: PermissionScope
readonly seed: PermissionSnapshot
/** The project as the frame shows it. */
readonly project: { readonly id: string; readonly label: string }
/** Its tabs: the modules' pages in a project this person may open, translated and absolute. */
readonly tabs: readonly NavLink[]
}
/**
* The project route's loader (`$org.$workspace.$project.tsx`). The project has to exist in this workspace of
* this organization: a project of another tenant, an unknown slug and a project the person has no place in
* are one 404 (`tenancy.project.get` resolves the whole path, each step a scoped read). The answer seeds the
* browser's `useCan` and `<Can>` (`AppTransport`).
*/
export async function projectLayoutData(
slugs: { readonly org: string; readonly workspace: string; readonly project: string },
request: Request = getRequest(),
): Promise<ProjectLayoutData> {
const page = await pageContext(request)
if (!page.signedIn) toSignIn(page.path)
const scope = { org: slugs.org, workspace: slugs.workspace, project: slugs.project }
const [project, seed] = await orFallback(page.path, () =>
Promise.all([page.caller.call(projectGet, scope), permissionsIn(scope, request)]),
)
const base = `/${slugs.org}/${slugs.workspace}/${slugs.project}`
const on = await orFallback(page.path, () => flagsOn(page.container, PROJECT_ENTRIES, scope))
return {
scope,
seed,
project: { id: slugs.project, label: project.name },
tabs: [
{ scope: "project", label: page.i18n.message("project.overview"), href: base, end: true, icon: "chart" },
...flagged(PROJECT_ENTRIES, on)
.filter((entry) => entry.permission === undefined || allows(seed, entry.permission as PermissionKey))
.map((entry) => ({
scope: "project" as const,
label: page.i18n.message(entry.label),
href: `${base}/${entry.href}`,
...(entry.icon ? { icon: entry.icon } : {}),
})),
],
}
}
/** The module route's loader data: the module's own `load` result and where it is. */
export interface ModulePageData {
readonly title?: string
readonly module: string
readonly segments: readonly string[]
readonly locale: string
/** The module's own data type, serializable by F10; the route only hands it to the module's view. */
// biome-ignore lint/suspicious/noExplicitAny: each module's data type is its own
readonly data: any
}
function sectionTitle(
i18n: PageContext["i18n"],
scope: "workspace" | "project",
module: string,
segments: readonly string[],
): string | undefined {
const nav = webRuntime().registry.nav.filter((entry) => entry.scope === scope && entry.module === module)
const path = [module, ...segments].join("/")
const section = nav.find((entry) => entry.href === path) ?? nav.find((entry) => entry.href === module)
return section ? `${i18n.message(section.label)} · ${appBrand.name}` : undefined
}
/**
* The one module route's loader (`/<org>/<workspace>/<project>/<module>/...`), below the project route, whose
* loader has the project check and the permission seed (`projectLayoutData`). A signed-out visitor is sent
* to sign in (302). A segment no registered module answers, a path the module does not know and a record of
* another tenant are the same not-found page; a missing permission is the forbidden page (403). Each status
* is set before the render starts.
*/
export async function modulePageData(
params: ModulePageParams,
request: Request = getRequest(),
): Promise<ModulePageData> {
const page = await pageContext(request)
if (!page.signedIn) toSignIn(page.path)
const { module, _splat, search, ...slugs } = params
if (!webRuntime().registry.modules.some((entry) => entry.id === module)) pageNotFound()
// `Object.hasOwn`: a segment named `constructor` is not a module.
const load = Object.hasOwn(modulePages, module) ? modulePages[module] : undefined
if (!load) pageNotFound()
const segments = segmentsOf(_splat)
const definition = await load()
const data = await loaded(
definition.load({
slugs,
segments,
search: searchOf(search),
caller: page.caller,
i18n: page.i18n,
container: page.container,
}),
)
const title = sectionTitle(page.i18n, "project", module, segments)
return { module, segments, locale: page.i18n.locale, data, ...(title ? { title } : {}) }
}
/** The route of a module's pages above a project: `/<org>/<workspace>/modules/<module>/...`. */
export interface WorkspacePageParams {
readonly org: string
readonly workspace: string
readonly module: string
readonly _splat?: string
readonly search?: string
}
/** The workspace page's loader data: the module's own, and the seed of `useCan` in the workspace. */
export interface WorkspacePageData extends ModulePageData {
readonly scope: PermissionScope
readonly seed: PermissionSnapshot
}
/**
* The one route of the modules' pages above a project, below the workspace route. A signed-out visitor is sent
* to sign in (302). A workspace of another organization, a segment no registered module answers and a path the
* module does not know are one not-found page; a page's `permission` the person does not hold in the workspace
* is the forbidden page, before `load` runs. Each status is set before the render starts.
*/
export async function workspacePageData(
params: WorkspacePageParams,
request: Request = getRequest(),
): Promise<WorkspacePageData> {
const page = await pageContext(request)
if (!page.signedIn) toSignIn(page.path)
const { module, _splat, search, ...slugs } = params
if (!webRuntime().registry.modules.some((entry) => entry.id === module)) pageNotFound()
// `Object.hasOwn`: a segment named `constructor` is not a module.
const load = Object.hasOwn(workspacePages, module) ? workspacePages[module] : undefined
if (!load) pageNotFound()
const seed = await orFallback(page.path, () => permissionsIn(slugs, request))
const definition = await load()
if (definition.permission !== undefined && !allows(seed, definition.permission as PermissionKey)) pageForbidden()
const segments = segmentsOf(_splat)
const data = await loaded(
definition.load({
slugs,
segments,
search: searchOf(search),
caller: page.caller,
i18n: page.i18n,
container: page.container,
}),
)
const title = sectionTitle(page.i18n, "workspace", module, segments)
return { module, segments, locale: page.i18n.locale, data, scope: slugs, seed, ...(title ? { title } : {}) }
}
/**
* Runs a loader and reports what it leaves unhandled, as Next's `onRequestError` did (Start has no such
* hook, M5). A redirect and a not-found are control flow and pass through unreported (report.ts).
*/
export async function reported<T>(
routePath: string,
run: () => Promise<T>,
request: Request = getRequest(),
): Promise<T> {
try {
return await run()
} catch (error) {
const { reportFailure } = await import("./report")
reportFailure(error, request, { routePath, routeType: "loader" })
throw error
}
}
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's theme from the platform, read on the server for every page (brand-kit BK12). The
// deployment's organization is the one its token belongs to; its branding is what the console saved,
// and a change there applies to the next page without a rebuild. A server-side cache keeps the last
// answer per organization and revalidates it with its ETag; no cookie and no browser storage holds it.
//
// An answer that is missing, refused, late or malformed gives the empty string, so the page keeps the
// kit's defaults from the stylesheet. The platform's branding never blocks a page for longer than the
// kit's timeout.
import { checkLogo, LOGO_LIMIT_BYTES } from "@conusai-kit/brand/logo/check"
import {
createPlatformThemeCache,
fetchPlatformTheme,
type PlatformTheme,
type PlatformThemeCache,
platformThemeCss,
} from "@conusai-kit/brand/platform-theme"
/** The deployment contract fields the theme read needs. */
export interface PlatformThemeDeployment {
readonly CONUSAI_API_URL: string
readonly CONUSAI_API_TOKEN: string
readonly CONUSAI_ORG_SLUG?: string | undefined
}
export interface PlatformThemeReaderOptions {
readonly cache?: PlatformThemeCache
readonly fetch?: typeof fetch
}
/**
* A reader of the deployment's organization theme as a stylesheet, with its own cache. The process
* shares one reader (below); a test builds its own with a fake fetch.
*/
export function createPlatformThemeReader(options: PlatformThemeReaderOptions = {}) {
const cache = options.cache ?? createPlatformThemeCache()
return async (deployment: PlatformThemeDeployment): Promise<string> => {
const orgSlug = deployment.CONUSAI_ORG_SLUG?.trim() ?? ""
if (orgSlug === "") return ""
const theme = await fetchPlatformTheme({
apiUrl: deployment.CONUSAI_API_URL,
token: deployment.CONUSAI_API_TOKEN,
orgSlug,
cache,
...(options.fetch ? { fetch: options.fetch } : {}),
})
return theme ? platformThemeCss(theme) : ""
}
}
const themeCache = createPlatformThemeCache()
const readOrganizationTheme = createPlatformThemeReader({ cache: themeCache })
/** The organization's theme roles, for the identity service's hosted pages; nothing without one. */
export async function organizationThemeRoles(deployment: PlatformThemeDeployment): Promise<PlatformTheme | null> {
const orgSlug = deployment.CONUSAI_ORG_SLUG?.trim() ?? ""
if (orgSlug === "") return null
return fetchPlatformTheme({
apiUrl: deployment.CONUSAI_API_URL,
token: deployment.CONUSAI_API_TOKEN,
orgSlug,
cache: themeCache,
})
}
/** The organization's theme as the page's stylesheet, or nothing when the page should keep the kit's defaults. */
export function organizationThemeStyle(deployment: PlatformThemeDeployment): Promise<string> {
return readOrganizationTheme(deployment)
}
/** Where the app serves the organization's logo from (`api.brand.logo.ts`): the platform's address stays on the server. */
export const ORGANIZATION_LOGO_PATH = "/api/brand/logo"
/**
* The organization's name and logo on Cloud, which the app's own (`config/src/brand.ts`) stand in for. One read with
* the theme's, from the same cache.
*/
export async function organizationIdentity(
deployment: PlatformThemeDeployment,
): Promise<{ readonly name?: string; readonly logo?: string }> {
const theme = await organizationThemeRoles(deployment)
return {
...(theme?.name ? { name: theme.name } : {}),
...(theme?.logoUrl ? { logo: ORGANIZATION_LOGO_PATH } : {}),
}
}
const LOGO_TTL_MS = 5 * 60_000
let logo: { readonly url: string; readonly at: number; readonly type: string; readonly bytes: Uint8Array } | undefined
/**
* The organization's logo, fetched on the server and checked as an uploaded logo is (`checkLogo`), or null. The
* deployment token goes only to the platform's own origin; a logo elsewhere is fetched without it.
*/
export async function cloudOrganizationLogo(
deployment: PlatformThemeDeployment,
fetchImpl: typeof fetch = fetch,
): Promise<{ readonly type: string; readonly bytes: Uint8Array } | null> {
const theme = await organizationThemeRoles(deployment)
if (!theme?.logoUrl) return null
const platform = new URL(deployment.CONUSAI_API_URL)
const url = new URL(theme.logoUrl, platform.origin)
if (logo && logo.url === url.href && Date.now() - logo.at < LOGO_TTL_MS) return logo
try {
const response = await fetchImpl(url, {
redirect: "error",
signal: AbortSignal.timeout(2000),
headers: url.origin === platform.origin ? { authorization: `Bearer ${deployment.CONUSAI_API_TOKEN}` } : {},
})
if (!response.ok) return null
const bytes = new Uint8Array(await response.arrayBuffer())
if (bytes.byteLength > LOGO_LIMIT_BYTES) return null
const type = response.headers.get("content-type")?.split(";")[0]?.trim() ?? ""
if (!checkLogo(type, bytes).ok) return null
logo = { url: url.href, at: Date.now(), type, bytes }
return logo
} catch {
return null
}
}
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// biome-ignore-all assist/source/organizeImports: keep optional imports inside template prune markers.
// The ports the web process binds, by token, from the platform clients (config/src/platform.ts) that
// the worker's `bindModulePorts` is built from too. A module resolves a port and never imports an
// adapter (spec §5.6), so what a procedure finds in a request is decided here.
//
// What a request does not get is on purpose: the audit and usage sinks of the files and proposals
// modules, and the job scope. They belong to the outbox and the queue, which only the worker runs
// (apps/worker/src/ports.ts); a request writes its audit event and its usage fact itself, in the
// router, after the procedure has answered.
import {
currentObservability,
excludedPageClassNamesOf, // kit:documents
type PlatformClients,
} from "@app/config"
import { moduleAdopted, moduleSelected } from "@app/modules"
import { ACCOUNT_CREDENTIAL, ACCOUNT_LOG, ACCOUNT_PRIVACY, ACCOUNT_REGISTRY } from "@app/modules/account"
import { AGENT_MODEL, gatewayAgentModel } from "@app/modules/agent" // kit:agent
import * as documents from "@app/modules/documents" // kit:documents
import * as knowledge from "@app/modules/knowledge" // kit:knowledge
import type { Container, Registry } from "@conusai-kit/core"
import * as data from "@conusai-kit/data"
import {
FILES_REGISTRY,
filesDeps, // kit:knowledge
platformObjectStore,
} from "@conusai-kit/files"
import { GUIDES_PLATFORM, GUIDES_REGISTRY, guidesPlatform } from "@conusai-kit/guides" // kit:guides
import { REQUEST_CREDENTIAL } from "@conusai-kit/host/postgres"
import {
createKnowledge, // kit:knowledge
createPrivacy,
} from "@conusai-kit/platform"
import { PROPOSALS_REGISTRY } from "@conusai-kit/proposals"
import * as support from "@conusai-kit/support" // kit:support
// kit:support:begin
type ProcessingScope = Parameters<support.SupportProcessingStatusReader["read"]>[0]
interface ProcessingCounts {
readonly processing: number
readonly extracted: number
readonly failed: number
}
/**
* The app's pipelines, each counting what a project has in process, for Support's processing status
* (`support.projectProcessingStatus.get`). The documents module's extraction is the template's one; an app without
* it has nothing in process, and the status says so ("empty").
*/
const PROCESSING_SOURCES: ReadonlyArray<(container: Container, scope: ProcessingScope) => Promise<ProcessingCounts>> = [
// kit:documents:begin
async (container, scope) => {
if (!moduleSelected("documents")) return { processing: 0, extracted: 0, failed: 0 }
const rows = await container
.resolve(data.DATA_SOURCE)
.forScope(scope)
.of(documents.documents)
.list({ limit: 1_000 })
const count = (status: string): number => rows.items.filter((row) => row.status === status).length
return { processing: count("processing"), extracted: count("extracted"), failed: count("failed") }
},
// kit:documents:end
]
/** A project's processing status: the sum over the app's pipelines. */
function processingStatus(container: Container): support.SupportProcessingStatusReader {
return {
read: async (scope) => {
const counts = await Promise.all(PROCESSING_SOURCES.map((source) => source(container, scope)))
const sum = (key: keyof ProcessingCounts): number => counts.reduce((total, each) => total + each[key], 0)
const processing = sum("processing")
const extracted = sum("extracted")
const failed = sum("failed")
const state = failed > 0 ? "failed" : processing > 0 ? "processing" : extracted > 0 ? "ready" : "empty"
return { state, processing, extracted, failed }
},
}
}
// kit:support:end
export function bindWebPorts(container: Container, platform: PlatformClients, registry: Registry): void {
// Blob, the AI gateway and OCR. A procedure that needs a short model call or an OCR read inside a
// request finds them here exactly as a job finds them in the worker. Long work is still a job
// (AGENTS.md): this is the capability, not a licence to wait on it.
container.value(data.OBJECT_STORE, platformObjectStore({ blob: platform.objectStore }))
if (moduleSelected("documents")) {
// kit:documents:begin
container.value(documents.MODEL_GATEWAY, platform.models)
container.value(documents.DOCUMENT_MODEL, platform.model)
const excluded = excludedPageClassNamesOf()
if (excluded) container.value(documents.DOCUMENT_EXCLUDED_PAGE_CLASSES, documents.pageClassesOf(excluded))
container.value(documents.DOCUMENT_OCR, documents.sdkDocumentOcr(platform.documents))
// kit:documents:end
}
if (moduleSelected("agent")) {
// kit:agent:begin
// The model of the Agent in the right panel: the AI gateway's, so metering and the spend cap stay there. A turn
// streams to the person from this process, so the worker's binding does not reach it.
container.value(
AGENT_MODEL,
gatewayAgentModel({
apiUrl: platform.deployment.CONUSAI_API_URL,
token: platform.deployment.CONUSAI_API_TOKEN,
model: platform.model,
}),
)
// kit:agent:end
}
// The registry the files and proposals modules read their extension points from.
container.value(FILES_REGISTRY, registry)
container.value(PROPOSALS_REGISTRY, registry)
// The registry the account module reads every installed module's share of an account from.
container.value(ACCOUNT_REGISTRY, registry)
// The request's credential under the account module's own name for it: the host binds it per request.
container.register(ACCOUNT_CREDENTIAL, (c) => c.resolve(REQUEST_CREDENTIAL), { lifetime: "scoped" })
// Why a module's share of an export failed: the operator's, redacted like every other line.
container.value(ACCOUNT_LOG, { warn: (message) => currentObservability()?.log.warn(message) })
// The account page's platform analytics erasure and export (ADR-049): the deployment token
// (`privacy:subjects` in conusai.json) on the server only, and the same token as the secret the
// module derives its ticket MAC key from (HKDF, a purpose-specific label: the token itself never
// keys a MAC; a rotated token makes open tickets NotFound and the page asks again). The fake
// platform answers the same routes, so an export is complete offline too.
const privacy = createPrivacy(platform.deployment)
container.value(ACCOUNT_PRIVACY, { ...privacy, ticketKey: platform.deployment.CONUSAI_API_TOKEN })
if (moduleSelected("knowledge")) {
// kit:knowledge:begin
// The knowledge module: the platform's knowledge service a request may call, and the enablement
// platform the settings procedures check against. The index/remove jobs stay in the worker; a request
// only writes the outbox event. Lazy: the files deps resolve the request container's data source.
container.register(knowledge.KNOWLEDGE_SEARCH, (c) =>
knowledge.platformKnowledgeSearch(createKnowledge(platform.deployment), filesDeps(c), {
environment: platform.deployment.CONUSAI_ENVIRONMENT,
}),
)
// What the status read asks: a read may not resolve KNOWLEDGE_SEARCH.
container.register(knowledge.KNOWLEDGE_READINESS, () =>
knowledge.platformKnowledgeReadiness(createKnowledge(platform.deployment)),
)
container.value(knowledge.KNOWLEDGE_ENABLEMENT, knowledgeEnablement(platform))
// kit:knowledge:end
}
if (moduleAdopted("guides")) {
// kit:guides:begin -- the guides module: the platform's Guides runtime with the deployment token
// (`guides:runtime` in conusai.json), or the offline mode of the fake platform (spec §7.3).
container.value(GUIDES_REGISTRY, registry)
container.register(GUIDES_PLATFORM, () =>
guidesPlatform({
apiUrl: platform.deployment.CONUSAI_API_URL,
token: platform.deployment.CONUSAI_API_TOKEN,
fake: platform.mode === "fake",
}),
)
// kit:guides:end
}
if (moduleAdopted("support")) {
// kit:support:begin -- the support module: widget sessions minted with the deployment token
// (`support:widget-sessions` in conusai.json) for the channel `CONUSAI_SUPPORT_CHANNEL` names; the fake
// platform has no Support service, so the launcher stays away there.
container.register(support.SUPPORT_PROCESSING_STATUS, (c) => processingStatus(c))
container.register(support.SUPPORT_PLATFORM, () =>
support.supportPlatform({
apiUrl: platform.deployment.CONUSAI_API_URL,
token: platform.deployment.CONUSAI_API_TOKEN,
channel: process.env.CONUSAI_SUPPORT_CHANNEL,
fake: platform.mode === "fake",
}),
)
// kit:support:end
}
}
// kit:knowledge:begin
function knowledgeEnablement(platform: PlatformClients) {
// The enablement flow runs against the platform's own APIs with the deployment credentials.
return knowledge.sdkDocumentAiPlatform({
apiUrl: platform.deployment.CONUSAI_API_URL,
token: platform.deployment.CONUSAI_API_TOKEN,
})
}
// kit:knowledge:end
// Read the server-selected runtime ports at authorized dispatch, never browser input.
export const nativeHelpSelectors = {
// kit:support:begin
supportChannel: (container: Container): string | undefined => {
if (!container.has(support.SUPPORT_PLATFORM)) return undefined
const port = container.resolve(support.SUPPORT_PLATFORM)
return port.mode === "platform" ? port.channel : undefined
},
// kit:support:end
// kit:guides:begin
guidesIdentityNamespace: (container: Container): string | undefined => {
if (!container.has(GUIDES_PLATFORM)) return undefined
const port = container.resolve(GUIDES_PLATFORM)
return port.mode === "platform" ? port.identityNamespace : undefined
},
// kit:guides:end
}
+88
View File
@@ -0,0 +1,88 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The choices a person makes about how the app looks to them: the language, the theme and the frame's layout.
//
// language A signed-in person's choice is their own setting, kept on their user row
// (`tenancy.me.update`) so it follows them to every browser; a visitor's choice is a
// cookie. Precedence: the person's setting, then the browser's Accept-Language when it
// names a language the app serves, then the app's default -- and for a visitor the
// cookie takes the place of the person's setting. An organization's default language
// reaches a person once, as their own setting, when they join (`organization-language.ts`).
// theme A cookie, because it is about this screen. The server reads it, so the first paint
// is already in the chosen theme and nothing flashes. `system` follows the device: only the
// browser knows that, so a script in the head sets the class before the first paint.
// layout Two cookies, for the same reason: the first paint has the sidebar as it was left and the
// right panel at its width. The kit stores nothing; the frame tells the host (`frame-layout.ts`).
// workspace The workspace the person was last in, per organization: an organization's own page (Settings) is
// shown in it, its sidebar and its breadcrumb, as the reference screen does.
//
// No cookie holds anything but a short fixed word or a number, so none needs to be signed or sealed.
import { type Locale, negotiateLocale } from "@conusai-kit/i18n"
import {
LOCALE_COOKIE,
PANEL_WIDTH_COOKIE,
SIDEBAR_COOKIE,
THEME_COOKIE,
type Theme,
WORKSPACE_COOKIE,
} from "../ui/preference-cookies"
export type { Theme }
export const LOCALES: readonly Locale[] = ["en", "lt"]
/** One cookie's value out of a `Cookie` header. */
export function cookieValue(header: string | null | undefined, name: string): string | undefined {
for (const part of (header ?? "").split(";")) {
const [key, ...rest] = part.trim().split("=")
if (key === name) {
try {
return decodeURIComponent(rest.join("="))
} catch {
return undefined
}
}
}
return undefined
}
/** The theme the cookie names; anything else is the brand's default, and the brand is dark-first. */
export function themeOf(cookieHeader: string | null | undefined, fallback: Theme = "dark"): Theme {
const theme = cookieValue(cookieHeader, THEME_COOKIE)
return theme === "light" || theme === "system" || theme === "dark" ? theme : fallback
}
/** The sidebar, open unless the person closed it. */
export function sidebarOpenOf(cookieHeader: string | null | undefined): boolean {
return cookieValue(cookieHeader, SIDEBAR_COOKIE) !== "closed"
}
/** The right panel's width, inside what the panel can take (320 to 640 px); anything else is its default. */
export function panelWidthOf(cookieHeader: string | null | undefined): number | undefined {
const width = Number(cookieValue(cookieHeader, PANEL_WIDTH_COOKIE))
return Number.isInteger(width) && width >= 320 && width <= 640 ? width : undefined
}
/** The slug of the workspace the person was last in, in this organization; a cookie of another organization is none. */
export function lastWorkspaceOf(cookieHeader: string | null | undefined, org: string): string | undefined {
const [organization, workspace, ...rest] = (cookieValue(cookieHeader, WORKSPACE_COOKIE) ?? "").split("/")
return organization === org && workspace && rest.length === 0 ? workspace : undefined
}
export interface LocaleInputs {
/** The signed-in person's own setting (their user row). */
readonly userLocale?: string
readonly cookieHeader?: string | null
readonly acceptLanguage?: string | null
/** The app's default (`appBrand.defaultLocale`). */
readonly appLocale: string
}
/** The language a page is rendered in. */
export function localeOf(inputs: LocaleInputs): Locale {
const chosen = inputs.userLocale ?? cookieValue(inputs.cookieHeader, LOCALE_COOKIE)
// The app's default is the browser's least wanted language, so it is the answer only when the browser names none the
// app serves: the kit's negotiation reads the header by its own weights.
const acceptLanguage = [inputs.acceptLanguage, `${inputs.appLocale};q=0.001`].filter(Boolean).join(",")
return negotiateLocale({ ...(chosen ? { userLocale: chosen } : {}), acceptLanguage })
}
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a project offers, in one place: the pages of the modules, as the organization home, the
// workspace page and the project home all list them (spec §3.1, §4.1). Server-only: it reads the manifests,
// so a page's loader hands the entries to its view (F10).
//
// A module with pages under a project says so in its manifest (`ui.nav`, scope `project`) and
// `modules/pages.ts` mounts them (`modulePages`); nothing here is edited to add one. The documents
// module is the exception that proves the rule: its two pages are routes of the shell
// (`src/routes/$org.$workspace.$project.documents.*`), so the shell lists it by name.
import { PROFILE } from "@app/config"
import { appModules } from "@app/modules"
import { moduleSelected } from "@app/modules/inclusion" // kit:documents
import type { Profile } from "@conusai-kit/platform"
import type { ProjectEntry } from "../ui/projects"
/**
* Documents, then every project-level entry the manifests declare, by their `order` (core-5): lower first across
* modules, equal or absent ones (Documents among them, at 0) in this order.
*/
export function projectEntriesFor(profile: Profile): readonly ProjectEntry[] {
return [
...(moduleSelected("documents") ? [{ href: "documents", label: "documents.title", module: "documents" }] : []), // kit:documents
...appModules.flatMap((module) =>
(module.ui?.nav ?? [])
.filter((item) => item.scope === "project")
.filter(() => profile !== "convex" || ["notes", "files"].includes(module.id))
.map((item) => ({
href: item.href,
label: item.label,
module: module.id,
...(item.icon ? { icon: item.icon } : {}),
...(item.permission === undefined ? {} : { permission: item.permission }),
...(item.order === undefined ? {} : { order: item.order }),
...(item.flag === undefined ? {} : { flag: item.flag }),
})),
),
].sort((a: ProjectEntry, b: ProjectEntry) => (a.order ?? 0) - (b.order ?? 0))
}
export const PROJECT_ENTRIES: readonly ProjectEntry[] = projectEntriesFor(PROFILE)
/** The values a project's `moduleKey` can take when it is created from the shell. */
const moduleKeys = [...new Set(PROJECT_ENTRIES.map((entry) => entry.module))]
export const PROJECT_MODULE_KEYS: readonly string[] = moduleKeys.length ? moduleKeys : ["general"]
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// An error a page's loader or the Start handler left unhandled, sent to observability by
// `@conusai-sdk/observe-start/report`. Start has no `onRequestError` hook (M5), so the two places that see such
// an error call this: `reported` (src/server/page.ts) for a loader, and the server entry (src/server.ts) for
// anything that escapes the handler. A redirect and a not-found the app raised on purpose (`redirect()`,
// `notFound()`, the forbidden page of guard.ts) are control flow, not errors, and the report leaves them out.
import { currentObservability } from "@app/config"
import { type RequestErrorContext, reportRequestError } from "@conusai-sdk/observe-start/report"
/** Through the process's redacting `observe` handle; a process that never started one reports nothing. */
export function reportFailure(error: unknown, request: Request, context: RequestErrorContext): void {
const observe = currentObservability()
if (observe) reportRequestError(error, request, context, observe)
}
+114
View File
@@ -0,0 +1,114 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What one request gets: its principal, its container and its caller (spec §6.2). On Postgres and the
// one-login profiles that is the host's `kernelRequestContext`; on Convex the procedures run in the
// deployment, so the caller is the deployment's, with the same credential and the same tenancy rules.
import {
type AuthCheckOptions,
authenticateRequest, // kit:profile-convex
sessionRefKey, // kit:profile-convex
} from "@conusai-kit/auth"
import type { TransportCaller } from "@conusai-kit/core"
import {
credentialOf, // kit:profile-convex
hostTenancyCalls, // kit:profile-convex
type KernelRequestContext,
} from "@conusai-kit/host/postgres"
import type { UserIdentityAttributes } from "convex/server"
import {
appSession, // kit:profile-convex
authRoutes, // kit:profile-convex
hostSignIn, // kit:profile-convex
kernelContext,
rolesChecked, // kit:profile-convex
webRuntime, // kit:profile-convex
} from "./app"
import { convexIdentity, createConvexCaller } from "./convex-caller" // kit:profile-convex
import { streamedOrgId } from "./live-grants" // kit:profile-convex
import { supportOboCaller } from "./support-obo" // kit:support
export interface RequestContext extends KernelRequestContext {
/** The Convex profile: the identity this request's Convex calls carry (convex-caller.ts `convexIdentity`). */
readonly convexIdentity?: UserIdentityAttributes
}
export async function requestContext(
request: Request,
requestId?: string,
authCheck: AuthCheckOptions = {},
): Promise<RequestContext> {
// kit:profile-convex:begin
const current = webRuntime()
if (current.convex) return convexRequestContext(current, current.convex, request, requestId, authCheck)
// kit:profile-convex:end
const context = await kernelContext()(request, requestId, authCheck)
const obo = await supportOboCaller(request, context.container) // kit:support
if (obo) return { ...context, caller: obo } // kit:support
return context
}
// kit:profile-convex:begin
async function convexRequestContext(
current: ReturnType<typeof webRuntime>,
convex: NonNullable<ReturnType<typeof webRuntime>["convex"]>,
request: Request,
requestId: string | undefined,
authCheck: AuthCheckOptions,
): Promise<RequestContext> {
const auth = await authRoutes()
// API credentials come first. A deliberately invalid or empty bearer throws here and can never
// fall back to an ambient cookie; only a request with no API credential uses the selected
// session adapter, and a session whose principal does not match it fails closed.
const { principal, session } = await authenticateRequest(
request,
{ credentials: auth, session: await appSession() },
authCheck,
)
await rolesChecked()
const correlationId = requestId ?? request.headers.get("x-request-id") ?? globalThis.crypto.randomUUID()
const identity = convexIdentity({
principal,
credential: credentialOf(principal, session),
// The device session a derived service session is minted for, so its sign-out ends it.
...(session ? { identitySession: sessionRefKey(session.sessionRef) } : {}),
...(session ? { user: { email: session.profile.email, displayName: session.profile.displayName } } : {}),
})
const container = current.root.child()
return {
principal,
container,
// The tenancy rules around the deployment's caller; a grant change the write made there is
// announced to this process's own open streams of the organization.
caller: hostTenancyCalls(
current,
hostSignIn(),
principal,
container,
createConvexCaller({
...convex,
principal,
objectStore: current.platform.objectStore,
...(identity ? { identity } : {}),
}),
{ orgIdOf: streamedOrgId },
),
...(identity ? { convexIdentity: identity } : {}),
correlationId,
...(session
? {
user: {
email: session.profile.email,
displayName: session.profile.displayName,
locale: session.profile.locale,
},
}
: {}),
}
}
// kit:profile-convex:end
/** The caller for a route's loader: the same path a route handler takes. */
export async function callerFor(request: Request): Promise<TransportCaller> {
return (await requestContext(request)).caller
}
+109
View File
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// This app's HTTP mount: `@conusai-kit/host/http`, given what only this app knows. The routes, CSRF,
// the rate limit, `ConusAI-Txid`, the span, the audit event and the usage meter are the package's; what
// is here is how this app is composed, which profile it runs on and what it reports as ready.
import { appBrand, appRoot, expectedDialectMigrations, PROFILE, probePlatform } from "@app/config"
import { moduleSelected } from "@app/modules/inclusion"
import { isInteractiveSession } from "@conusai-kit/auth" // kit:support
import { AppError } from "@conusai-kit/core" // kit:support
import {
checkReadiness,
type HostReadiness,
hostHandler,
limitFor, // kit:support
problemResponse, // kit:support
requireCsrf, // kit:support
take, // kit:support
} from "@conusai-kit/host/http"
import { createSupportProxy } from "@conusai-kit/support/proxy" // kit:support
import {
convexCapabilityStatus, // kit:profile-convex
postgresHost,
webRuntime,
} from "./app"
import { serveLive } from "./live"
import { requestContext } from "./request"
const NOT_READY = { ready: false, checks: { store: false, reader: false, migrations: false, platform: false } }
/**
* `/api/ready`: the store, the reader and the migration level are there, and the platform API is
* reported (`checkReadiness` says why it is reported rather than required, and why the answer carries
* no reason). On Postgres it is the host's own; the other profiles answer the same shape here.
*/
async function readiness(): Promise<HostReadiness> {
if (PROFILE === "postgres") return postgresHost().readiness()
const { env, registry, observe, store } = webRuntime()
if (PROFILE === "mariadb" || PROFILE === "mongodb") {
if (!store) return NOT_READY
const profile = PROFILE
// One login: the reader check is the store's own, there being no second role to prove.
return checkReadiness({
store: () => store.store(),
reader: () => store.store(),
applied: () => store.applied(),
expected: () => expectedDialectMigrations(registry, profile, appRoot()),
platform: () => probePlatform(env.deployment),
warn: (message) => observe.log.warn(message),
})
}
// kit:profile-convex:begin
if (PROFILE === "convex") {
const capabilities = await convexCapabilityStatus()
const platform = await probePlatform(env.deployment)
return { ready: capabilities.ready, checks: { ...capabilities, platform } }
}
// kit:profile-convex:end
return NOT_READY
}
// kit:support:begin -- the support widget's same-origin proxy (joint contract §12, @conusai-kit/support):
// the widget's contact requests and its event stream reach the platform through this origin, so the
// page keeps `connect-src 'self'` and `frame-src 'none'`. Only a member this app signed in passes
// (the session cookie, never an API key, and CSRF on a POST); the widget's own bearer is forwarded.
let supportProxy: ((request: Request) => Promise<Response>) | undefined
function serveSupport(request: Request, requestId: string): Promise<Response> {
const { platform } = webRuntime()
if (!moduleSelected("support") || platform.mode === "fake")
return Promise.resolve(
problemResponse(
new AppError({
type: "/problems/not_found",
title: "Not found",
status: 404,
code: "not_found",
detail: "no procedure support",
}),
),
)
supportProxy ??= createSupportProxy({
apiUrl: platform.deployment.CONUSAI_API_URL,
prefix: "/api/support",
authorize: async (admission) => {
const { principal } = await requestContext(admission, requestId)
if (!principal || !isInteractiveSession(principal))
throw new AppError({
type: "/problems/unauthenticated",
title: "Unauthenticated",
status: 401,
code: "unauthenticated",
})
requireCsrf(admission, principal)
const limit = limitFor({ kind: "effect", scope: "user", rateLimit: "support" }, principal)
take(limit.key, limit.bucket)
},
refuse: problemResponse,
})
return supportProxy(request)
}
// kit:support:end
export const handle = hostHandler({
runtime: webRuntime,
requestContext,
readiness,
openapiTitle: `${appBrand.name} API`,
live: serveLive,
support: serveSupport, // kit:support
})
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The shape of a one-login store (the mariadb and mongodb profiles, dialect/): what app.ts keeps of it
// and what `/api/ready` asks of it. Types only, so the composition root and the engines meet here.
import type { DataSource, SystemDataSource } from "@conusai-kit/data"
import type { LiveLog } from "@conusai-kit/live-server"
/** A data source of the mariadb or mongodb profile: what the request container and the kit's ports need. */
export type DialectDataSource = DataSource & SystemDataSource & { close(): Promise<void> }
/** The readiness probes of a one-login store (router.ts `/api/ready`): its query and its recorded migrations. */
export interface StoreProbes {
store(): Promise<unknown>
applied(): Promise<ReadonlySet<string>>
/** Resolves once the store is proven usable at boot (MongoDB: a replica set, AT-D15). */
boot(): Promise<void>
}
/** One engine's data source and probes, over the URL of its profile. */
export interface EngineStore {
readonly data: DialectDataSource
readonly probes: StoreProbes
/** The engine's live log (mariadb, mongodb); built once, on the first stream. */
readonly liveLog?: () => LiveLog
}
+51
View File
@@ -0,0 +1,51 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Support's OBO-only MCP caller. Every other request and every non-MCP route
// uses the ordinary session adapter in request.ts.
import type { Container, TransportCaller } from "@conusai-kit/core"
import { createSupportCallerResolver, createSupportToolCaller, ON_BEHALF_OF_HEADER } from "@conusai-kit/support"
import { createTenancyHooks } from "@conusai-kit/tenancy"
import { createOboVerifier, InMemoryReplayStore } from "@conusai-sdk/support"
import { webRuntime } from "./app"
// Request-local verifiers must share replay protection across this web process.
// Multiple web replicas require a shared atomic ReplayStore (see the SDK contract).
const replayStore = new InMemoryReplayStore()
export async function supportOboCaller(request: Request, container: Container): Promise<TransportCaller | null> {
if (new URL(request.url).pathname !== "/api/mcp" || !request.headers.has(ON_BEHALF_OF_HEADER)) return null
const runtime = webRuntime()
if (!runtime.data) return null
const deployment = runtime.env.deployment
const issuer = deployment.CONUSAI_API_URL.replace(/\/api\/?$/, "")
const resolve = createSupportCallerResolver({
verify: createOboVerifier({
jwksUrl: `${issuer}/.well-known/conusai-obo-jwks.json`,
audience: deployment.CONUSAI_PUBLIC_URL ?? "",
issuer,
identityNamespace: "app",
organization: deployment.CONUSAI_ORG_ID ?? "",
project: deployment.CONUSAI_PROJECT_ID,
environment: deployment.CONUSAI_ENVIRONMENT_ID ?? "",
replayStore,
}),
organization: deployment.CONUSAI_ORG_ID ?? "",
project: deployment.CONUSAI_PROJECT_ID,
environment: deployment.CONUSAI_ENVIRONMENT_ID ?? "",
resolveMember: async (delegation) => ({
organizationId: delegation.appTenant ?? deployment.CONUSAI_ORG_ID ?? "",
userId: delegation.subject,
}),
})
return createSupportToolCaller(
{
registry: runtime.registry,
container,
tenancy: createTenancyHooks({ data: runtime.data, registry: runtime.registry }),
...(runtime.serviceReadiness ? { serviceReadiness: runtime.serviceReadiness } : {}),
...(runtime.prepareServices ? { prepareServices: runtime.prepareServices } : {}),
},
resolve,
request,
)
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a web process adds to a request's tenancy calls is `@conusai-kit/host/postgres`'s, on every profile.
export {
ADMISSION_AUDIT,
GRANT_CHANGING_PROCEDURES,
MEMBER_INVITE,
mayCreateOrganization,
ORGANIZATION_CREATE,
organizationExists,
type TenancyCallLog,
type TenancyCallOptions,
withTenancyCalls,
} from "@conusai-kit/host/postgres"
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The account page's interactive half: the account module's export and deletion panels over this
// app's transport, and, once a deletion answered, the end of the browser modules' context -- every
// enabled module's `account-deletion` lifecycle handler (joint contract §12), which clears what it
// kept in the browser for the account.
import {
type AccountAnalyticsLabels,
AccountAnalyticsPanel,
AccountDeletionPanel,
type AccountDeletionProps,
type AccountExportLabels,
AccountExportPanel,
} from "@app/modules/account/ui"
import type { ReactElement } from "react"
import { endClientModules } from "./client-module-list"
import { AppTransport } from "./transport"
export function AccountPanels({
exportLabels,
analyticsLabels,
deletion,
}: {
exportLabels: AccountExportLabels
/** The platform analytics panel (ADR-049); absent when this deployment has no privacy service. */
analyticsLabels?: AccountAnalyticsLabels
deletion: Omit<AccountDeletionProps, "onDeleted">
}): ReactElement {
return (
<AppTransport>
<AccountExportPanel labels={exportLabels} />
{analyticsLabels ? <AccountAnalyticsPanel labels={analyticsLabels} /> : null}
<AccountDeletionPanel {...deletion} onDeleted={() => endClientModules("account-deletion")} />
</AppTransport>
)
}
+89
View File
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The Agent in the frame's right panel, on every page of a workspace: a conversation in the project the person is
// in, or the workspace's own on a page with no project open (the projects list), where it is offered the
// workspace's tools. It is told where they are on every message, and its context line says where it works: the
// project's name, or the workspace's. Outside a workspace there is no button; a panel left open keeps its
// conversation for its place. What it is told is what is on screen, sent as `pageContext`, never stored.
import { moduleSelected } from "@app/modules/inclusion"
import { type AgentPanel, agentOccupant, createAgentPage, rootActionLabels } from "@conusai-kit/agent/assistant"
import { assistantClient } from "@conusai-kit/agent/thread"
import { useTransport } from "@conusai-kit/ui"
import { assistantLabels } from "@conusai-kit/ui/assistant"
import type { PanelOccupant } from "@conusai-kit/ui/patterns"
import { useEffect, useMemo, useState } from "react"
export interface AgentScope {
/** Where it works: the workspace, and the project when one is open. */
readonly slugs?: { readonly org: string; readonly workspace: string; readonly project?: string }
readonly workspaceName?: string
readonly projectName?: string
readonly user: string
readonly locale: string
readonly permissions: readonly string[]
/** The module's page on screen, by its path segment. */
readonly resource?: string
/** The record the right panel or the page has open. */
readonly recordId?: string
}
/**
* The record the right panel shows as the person moves along a list (the source viewer's row), which the frame
* reports through `AppFrame`'s `onRecordChange` without touching the address (core-10).
*/
export function useShownRecord(): readonly [string | null, (recordId: string | null) => void] {
return useState<string | null>(null)
}
export function useAgent(scope: AgentScope): (() => PanelOccupant) | undefined {
const { slugs, workspaceName, projectName, user, locale, permissions, resource, recordId } = scope
const { call, fetch } = useTransport()
const [page] = useState(() => createAgentPage())
const { org, workspace, project } = slugs ?? {}
useEffect(() => {
if (!org || !workspace) {
page.show(null)
return
}
const place = project ? projectName : workspaceName
page.show({
scope: { organization: org, workspace, ...(project ? { project } : {}) },
...(resource ? { resource } : {}),
...(recordId ? { recordId } : {}),
...(place ? { summary: place } : {}),
})
}, [page, org, workspace, project, workspaceName, projectName, resource, recordId])
const client = useMemo(
() =>
moduleSelected("agent") && org && workspace && fetch
? assistantClient({ slugs: { org, workspace, ...(project ? { project } : {}) }, locale, call, fetch })
: undefined,
[org, workspace, project, locale, call, fetch],
)
const allowed = moduleSelected("agent") && permissions.includes("assistant.turn.send")
const canApprove = permissions.includes("proposals.proposal.approve")
const canReject = permissions.includes("proposals.proposal.reject")
const panel = useMemo<AgentPanel | undefined>(
() =>
client && allowed
? {
client,
threadKey: `${user}:${org}:${workspace}:${project ?? ""}`,
labels: assistantLabels(locale),
rootActions: { labels: rootActionLabels(locale) },
canSend: true,
canApprove,
canReject,
}
: undefined,
[client, allowed, user, org, workspace, project, locale, canApprove, canReject],
)
// A panel left open follows the person to another project, or to the workspace with none open.
useEffect(() => {
if (panel) page.follow(panel)
}, [page, panel])
if (!panel) return undefined
return () => agentOccupant({ ...panel, page })
}
+59
View File
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// kit:custom-ui the first page, the sign-in and the signed-out page, on the kit's `AuthLayout`
// The way in: the brand's picture and words from `config/src/brand.ts`, the languages and the theme at the top, and
// the page's own card. The identity service's hosted pages draw the same layout, so a person does not notice
// leaving the app. Every word is a message the server passed in.
import { AuthCard, AuthLayout } from "@conusai-kit/ui/patterns"
import type { ReactElement, ReactNode } from "react"
import { AuthControls } from "./shell-controls"
import { useShell } from "./use-shell"
/** The app's logo in each theme, when the brand shows one on the way in. */
export function AuthMark({ light, dark }: { readonly light: string; readonly dark: string }): ReactElement {
return (
<>
<img className="kit-auth-logo app-logo-dark" src={dark} alt="" />
<img className="kit-auth-logo app-logo-light" src={light} alt="" />
</>
)
}
export function AuthScreen({
title,
description,
footer,
children,
}: {
title: string
description?: ReactNode
footer?: ReactNode
children?: ReactNode
}): ReactElement | null {
const shell = useShell()
if (!shell) return null
const { auth } = shell
const logo = auth.logo ? <AuthMark {...auth.logo} /> : undefined
return (
<AuthLayout
name={auth.name}
{...(logo ? { logo } : {})}
{...(auth.picture ? { picture: auth.picture } : {})}
{...(auth.tagline ? { tagline: auth.tagline } : {})}
highlights={auth.highlights}
controls={<AuthControls shell={shell} />}
>
<AuthCard
name={auth.name}
{...(logo ? { logo } : {})}
title={title}
{...(description ? { description } : {})}
{...(footer ? { footer } : {})}
>
{children}
</AuthCard>
</AuthLayout>
)
}
+91
View File
@@ -0,0 +1,91 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The e-mail and password form of the Convex Better Auth adapter (AUTH_PROVIDER=convex-better-auth):
// sign-in and sign-up. It posts to this app's own /api/auth, never to another origin, so the content
// security policy stays `connect-src 'self'`; the session cookie Better Auth sets is HTTP-only and the
// answer carries no token (the proxy removes it). No Better Auth client library is loaded: a sign-in
// is one request. Every message is one of a fixed set of keys, never the provider's own text.
import { FormLayout, TextField } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
export type BetterAuthFormError = "invalid" | "exists" | "disabled" | "rateLimited" | "unavailable" | "failed"
export interface BetterAuthFormProps {
readonly mode: "sign-in" | "sign-up"
readonly returnTo: string
readonly minPasswordLength: number
readonly labels: {
readonly name: string
readonly email: string
readonly password: string
readonly passwordHelp: string
readonly submit: string
readonly errors: Readonly<Record<BetterAuthFormError, string>>
}
}
const errorOf = (mode: BetterAuthFormProps["mode"], status: number, code: string | undefined): BetterAuthFormError => {
if (status === 429) return "rateLimited"
if (status >= 500) return "unavailable"
if (code === "BANNED_USER" || (status === 403 && mode === "sign-in")) return "disabled"
if (mode === "sign-up" && (status === 422 || code?.startsWith("USER_ALREADY_EXISTS"))) return "exists"
if (status === 401 || code === "INVALID_EMAIL_OR_PASSWORD") return "invalid"
return "failed"
}
/** The refusal in the form's own words, or null when the person is in. */
async function send(
mode: BetterAuthFormProps["mode"],
body: Record<string, string>,
): Promise<BetterAuthFormError | null> {
try {
const response = await fetch(`/api/auth/${mode}/email`, {
method: "POST",
headers: { "content-type": "application/json", accept: "application/json" },
credentials: "same-origin",
body: JSON.stringify(body),
})
if (response.ok) return null
const answer = (await response.json().catch(() => ({}))) as { code?: string }
return errorOf(mode, response.status, answer.code)
} catch {
return "unavailable"
}
}
export function BetterAuthForm({ mode, returnTo, minPasswordLength, labels }: BetterAuthFormProps): ReactElement {
const signUp = mode === "sign-up"
// From the form data, not the trimmed values: a password is exactly what was typed.
const submit = async (_: unknown, data: FormData): Promise<void> => {
const field = (name: string) => String(data.get(name) ?? "")
const body = { email: field("email"), password: field("password"), ...(signUp ? { name: field("name") } : {}) }
const error = await send(mode, body)
if (error) throw new Error(labels.errors[error])
// A full navigation: the next page is rendered on the server with the new session.
window.location.assign(returnTo)
}
return (
<FormLayout
method="post"
submitLabel={labels.submit}
onSubmit={submit}
describeError={(error) => (error as Error).message}
>
{signUp ? <TextField name="name" label={labels.name} required autoComplete="name" /> : null}
<TextField name="email" type="email" label={labels.email} required autoComplete="email" />
<TextField
name="password"
type="password"
label={labels.password}
description={signUp ? labels.passwordHelp : undefined}
required
minLength={minPasswordLength}
autoComplete={signUp ? "new-password" : "current-password"}
/>
</FormLayout>
)
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The error boundary's side of the page states. A route's `errorComponent` renders in the browser too, so
// it cannot ask the server for the person's language; the root route renders `BoundaryMessages` with the
// strings its loader already translated (F10), and the boundary below it reads them back. When the root
// loader itself failed there is no provider, and `RootErrorView` carries its own two languages.
import { Button } from "@conusai-kit/ui/primitives"
import { createContext, type ReactElement, type ReactNode, useContext, useEffect } from "react"
import { reportClientError } from "../observe/browser"
import { StateView } from "./state-view"
/** The page states' own strings, for when the root loader that translates them is what failed. */
const FALLBACK: Readonly<Record<"en" | "lt", BoundaryStrings>> = {
en: {
title: "Something went wrong",
body: "The app could not be shown. Try again.",
code: "Error code: {digest}",
retry: "Try again",
home: "Back to the start",
},
lt: {
title: "Kažkas nepavyko",
body: "Programos parodyti nepavyko. Bandykite dar kartą.",
code: "Klaidos kodas: {digest}",
retry: "Bandyti dar kartą",
home: "Grįžti į pradžią",
},
}
export interface BoundaryStrings {
readonly title: string
readonly body: string
/** `{digest}` is replaced with the error's digest, the code an operator finds in the server's log. */
readonly code: string
readonly retry: string
readonly home: string
}
const Strings = createContext<BoundaryStrings | undefined>(undefined)
export function BoundaryMessages({
strings,
children,
}: {
strings: BoundaryStrings
children: ReactNode
}): ReactElement {
return <Strings.Provider value={strings}>{children}</Strings.Provider>
}
export function ErrorView({ error, retry }: { error: Error & { digest?: string }; retry: () => void }): ReactElement {
const strings = useContext(Strings)
useEffect(() => {
reportClientError(error)
}, [error])
if (!strings) throw error
return (
<StateView
title={strings.title}
actions={
<>
<Button type="button" onClick={retry}>
{strings.retry}
</Button>
<Button asChild variant="outline">
<a href="/">{strings.home}</a>
</Button>
</>
}
>
<p role="alert">{strings.body}</p>
{error.digest ? <p>{strings.code.replace("{digest}", error.digest)}</p> : null}
</StateView>
)
}
/** The root route's error component: the boundary's strings when the root loader gave them, its own otherwise. */
export function RootErrorView({
error,
retry,
strings,
locale,
}: {
error: Error & { digest?: string }
retry: () => void
strings?: BoundaryStrings
locale?: string
}): ReactElement {
return (
<BoundaryMessages strings={strings ?? FALLBACK[locale === "lt" ? "lt" : "en"]}>
<ErrorView error={error} retry={retry} />
</BoundaryMessages>
)
}
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The client entries of the modules this app enabled (joint contract §12), composed once. The host
// (client-modules.tsx) mounts them beside every organization page; the sign-out control ends their
// context before the browser leaves the page. A module that is not listed contributes no browser
// code; a listed one's client entry is small and loads its provider or surface lazily. They are the
// panel entries: on the app frame nothing floats over the page, and Help is the way in (help.tsx).
import { moduleAdopted, moduleSelected } from "@app/modules/inclusion"
import {
type ClientLifecycleEvent,
type ClientModule,
composeClientModules,
type LifecycleReport,
} from "@conusai-kit/core"
import { guidesPanelClientModule } from "@conusai-kit/guides/client" // kit:guides
import { supportPanelClientModule } from "@conusai-kit/support/client" // kit:support
const enabled: ClientModule[] = []
// kit:guides:begin
if (moduleAdopted("guides"))
enabled.push(
moduleSelected("guides") ? guidesPanelClientModule : { id: "guides", lifecycle: guidesPanelClientModule.lifecycle },
)
// kit:guides:end
// kit:support:begin
if (moduleAdopted("support"))
enabled.push(
moduleSelected("support")
? supportPanelClientModule
: { id: "support", lifecycle: supportPanelClientModule.lifecycle },
)
// kit:support:end
export const composedClientModules = composeClientModules(enabled)
/**
* Ends the signed-in context of every enabled module in this tab. Sign-out is a full navigation, so
* no provider unmounts and no cleanup of theirs runs: each module's own `sign-out` (or
* `account-deletion`) handler clears what it keeps in the browser -- the guides progress and unsent
* events in `sessionStorage`, any stored support widget session. Every handler runs even when
* another fails, and the report names each outcome.
*/
export function endClientModules(event: Exclude<ClientLifecycleEvent, "tenant-switch">): Promise<LifecycleReport> {
return composedClientModules.runLifecycle(event)
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The browser half of the modules (joint contract §12): the client entries of the modules this app
// enabled (client-module-list.ts), mounted by kit-ui's generic host around every organization page.
// A module that is not listed there contributes no browser code at all; one that is listed loads its
// provider lazily, after the page has rendered. These providers are the kit's own browser packages
// (guides, support); they reach the API through the kit's transport (`useTransport`), on this
// origin. Module components use the data facade (`@conusai-kit/ui/data`) instead.
import { type ClientHostContext, ClientModuleHost } from "@conusai-kit/ui"
import type { ReactElement, ReactNode } from "react"
import { composedClientModules } from "./client-module-list"
import { AppTransport } from "./transport"
export function ClientModules({
context,
children,
}: {
context: ClientHostContext
/** The page: what asks the modules' surface coordinator for something sits below the host. */
children?: ReactNode
}): ReactElement {
return (
<AppTransport>
<ClientModuleHost client={composedClientModules} context={context}>
{children}
</ClientModuleHost>
</AppTransport>
)
}
+160
View File
@@ -0,0 +1,160 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The browser's half of the one API path (spec §6.3). A page's form calls a procedure over the
// same mount a CLI or a partner uses; there is no server action for a domain write, so one set of
// rules applies to every caller (decision AT-D1).
//
// Two shapes, one transport: `callContract` is typed by the procedure's own zod schemas -- the
// typed client of spec §6.2, from the contracts and not from a second description of them -- and
// `call` takes a name, for a form that is built from data. A module's `contract/` folder is
// client-safe by the layer rules (it imports no driver, no SDK and no server code), so a page may
// import its contracts; the kernel's own contracts live beside their handlers and are called on
// the server (`caller.call(contract, input)`).
//
// Modules call through `@conusai-kit/ui/data` (`useRead`, `useLive`, `useWrite`, `useUpload`), whose
// client this file's `csrfToken` and `sendBody` feed (transport.tsx); `call` remains for the shell's
// own forms. The shape of a call -- a contract, an input, problem+json out -- is the same in both.
import { keepaliveBeacon } from "@conusai-kit/ui"
import { DataError } from "@conusai-kit/ui/data"
export interface Problem {
title: string
status: number
code: string
detail?: string
errors?: { path: string; message: string }[]
}
const csrf = (): string => document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ?? ""
/** The page's CSRF token, for the data facade's client (transport.tsx). */
export const csrfToken = csrf
/**
* The page's unload path (kit-ui's `Beacon`): `call`'s mount, session cookie and CSRF token on a
* same-origin keep-alive request, so a call made as the page goes away (the guides engine's unsent
* events on `pagehide`) still arrives, and passes the CSRF wall like any other POST of this page.
*/
export const beacon = keepaliveBeacon({
url: (procedure) => `/api/v1/${procedure}`,
headers: () => ({ "x-csrf-token": csrf() }),
})
/**
* A procedure's contract, as much of it as a browser needs: the name to call and the two schemas
* that type the call. The shape is structural on purpose, so the browser bundle carries no import
* of the kernel at all.
*/
export interface ClientContract<I, O> {
readonly name: string
readonly input: { readonly _input: I }
readonly output: { readonly _output: O }
}
/** Calls a procedure, typed by its contract: the input and the answer are the schemas' own types. */
export async function callContract<I, O>(
contract: ClientContract<I, O>,
input: I,
options: { idempotencyKey?: string } = {},
): Promise<O> {
return call<O>(contract.name, input, options)
}
/**
* Calls a procedure by name, for a form whose fields are data rather than code. The session cookie
* travels on its own; the CSRF token is the page's.
*/
export async function call<T>(
procedure: string,
input: unknown,
options: { idempotencyKey?: string } = {},
): Promise<T> {
const response = await fetch(`/api/v1/${procedure}`, {
method: "POST",
credentials: "same-origin",
headers: {
"content-type": "application/json",
"x-csrf-token": csrf(),
...(options.idempotencyKey ? { "idempotency-key": options.idempotencyKey } : {}),
},
body: JSON.stringify(input ?? null),
})
const body: unknown = await response.json().catch(() => null)
if (!response.ok) throw refusalOf(response.status, body)
return body as T
}
/**
* This app's own `fetch` for a module route that is not a procedure call (a module's same-origin
* proxy and its event stream): the session cookie and the page's CSRF token, and nothing else. It
* refuses another origin, so a module cannot use it to send the token anywhere but here.
*/
export function authenticatedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const request = new Request(input instanceof Request ? input : new URL(String(input), window.location.href), init)
if (new URL(request.url).origin !== window.location.origin) {
return Promise.reject(new TypeError("authenticatedFetch only reaches this app's own origin"))
}
const headers = new Headers(request.headers)
headers.set("x-csrf-token", csrf())
return fetch(new Request(request, { headers, credentials: "same-origin" }))
}
/** A refusal as the server sent it (problem+json), or a bare status when it sent nothing readable. */
function refusalOf(status: number, body: unknown): DataError {
const problem = (body ?? {}) as Partial<Problem>
const normalized: Problem = {
title: problem.title ?? "Request failed",
status,
code: problem.code ?? "unknown",
...(problem.detail ? { detail: problem.detail } : {}),
...(problem.errors ? { errors: problem.errors } : {}),
}
return new DataError(status, normalized)
}
/**
* Sends the body of a procedure that is a stream of bytes (kit-core `.stream(field)`): a `PUT` to
* the same mount with the other fields of the input as the query. It is an `XMLHttpRequest` and not
* `fetch` because only a request can report how much of a body has left the browser, which is what
* an upload's progress bar is; the session cookie and the CSRF token travel as they do for `call`.
*
* A network failure rejects with a `TypeError`, the way `fetch` does, so a caller can tell it from
* a refusal (a `DataError`) and send the piece again.
*/
export function sendBody(
procedure: string,
query: Readonly<Record<string, string | number>>,
body: Blob,
options: { onProgress?: (sentBytes: number) => void; signal?: AbortSignal } = {},
): Promise<unknown> {
return new Promise((resolve, reject) => {
if (options.signal?.aborted) {
reject(new DOMException("the upload was cancelled", "AbortError"))
return
}
const params = new URLSearchParams(Object.entries(query).map(([key, value]) => [key, String(value)]))
const request = new XMLHttpRequest()
request.open("PUT", `/api/v1/${procedure}?${params.toString()}`)
request.withCredentials = true
request.setRequestHeader("content-type", "application/octet-stream")
request.setRequestHeader("x-csrf-token", csrf())
request.upload.onprogress = (event) => options.onProgress?.(event.loaded)
request.onerror = () => reject(new TypeError("network error"))
request.ontimeout = () => reject(new TypeError("network timeout"))
request.onabort = () => reject(new DOMException("the upload was cancelled", "AbortError"))
request.onload = () => {
let parsed: unknown = null
try {
parsed = JSON.parse(request.responseText)
} catch {
// A body that is not JSON (a gateway's error page) is a refusal with only a status.
}
if (request.status >= 200 && request.status < 300) resolve(parsed)
else reject(refusalOf(request.status, parsed))
}
options.signal?.addEventListener("abort", () => request.abort(), { once: true })
request.send(body)
})
}
+281
View File
@@ -0,0 +1,281 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// kit:custom-ui the HTML document and the app header, around every page
// The HTML document every page is rendered into: the brand layer on <html>, an organization's brand on
// <body>, and, on a page outside the app frame, the header with the app's name and logo. It takes its brand
// as a value so a test can render it with a customer's brand and prove what the page says; the root route
// (src/routes/__root.tsx) supplies the request's facts from its loader (locale, session, CSRF token, labels:
// `ShellData`, F10).
//
// The brand layer (`brand.style`: the roles the app's colours derive, light and dark, and its `--brand-*` tokens) is
// a stylesheet in <head> whose rule (`:root:root`) outranks the kit's theme in either mode, so the first paint already
// carries it: a branded app never paints the ConusAI palette first. When the platform gives the organization's theme,
// that theme is the one source of the colours and the brand layer keeps only its `--brand-*` tokens (`__root.tsx`), so
// the theme's own dark roles show; before, an inline brand colour on <html> beat them. It is computed on the server,
// so the browser's bundle never imports the brand package. An
// organization's colours go on <body> and not on an element below it: a menu and the phone's sheet are portals
// into <body>, and they keep its colours that way.
import type { AuthBrand } from "@app/config"
import type { UiLabels } from "@conusai-kit/ui/primitives"
import type { CSSProperties, ReactElement, ReactNode } from "react"
import { type BrowserObserveSettings, observeMeta } from "../observe/browser"
import type { BoundaryStrings } from "./boundary"
import type { HelpLabels } from "./help"
import type { Theme } from "./preference-cookies"
import { type PrivacyChoiceMessages, PrivacyChoices, PrivacyFooter } from "./privacy-choices"
/**
* The brand layer as a rule on the root, above the kit's theme (`:root` and `.light`) whatever order the stylesheets
* load in. Its values were checked when the brand was defined (colours and lengths only); a `<` is dropped all the
* same, so nothing in it can close the element.
*/
export function brandLayerCss(style: Readonly<Record<string, string>>): string {
const declarations = Object.entries(style).map(([name, value]) => `${name}:${value}`)
return declarations.length === 0 ? "" : `:root:root{${declarations.join(";").replaceAll("<", "")}}`
}
/** The brand without its colour roles, for a page the platform's organization theme colours: its `--brand-*` tokens. */
export function brandTokensOnly(style: Readonly<Record<string, string>>): Record<string, string> {
return Object.fromEntries(Object.entries(style).filter(([name]) => name.startsWith("--brand-")))
}
/** The app's own share image (src/routes/api.brand.og.ts): public, because a link preview is fetched without a session. */
export const APP_SHARE_IMAGE = "/api/brand/og"
/** What the document needs of the brand: its names, its logos and the token style the server computed. */
export interface ShellBrand {
readonly name: string
readonly shortName: string
readonly logo: { readonly light: string; readonly dark: string; readonly favicon: string }
/** `brandTokenStyle(brand)` of `@app/config`, computed on the server. */
readonly style: Readonly<Record<string, string>>
}
/** What a page says about itself to a browser tab, a share card and a search result. */
export interface BrandMetadata {
readonly title: string
readonly description: string
readonly applicationName: string
readonly icons: { readonly icon: string }
readonly openGraph: {
readonly siteName: string
readonly title: string
readonly type: "website"
readonly images: readonly {
readonly url: string
readonly width: number
readonly height: number
readonly alt: string
}[]
}
readonly twitter: { readonly card: "summary_large_image"; readonly title: string; readonly images: readonly string[] }
}
/**
* The brand's own name, and nothing else. `image` is the share image of a page; an organization's pages pass
* their own.
*/
export function brandMetadata(
brand: Pick<ShellBrand, "name" | "logo">,
image: string = APP_SHARE_IMAGE,
): BrandMetadata {
return {
title: brand.name,
description: brand.name,
applicationName: brand.name,
icons: { icon: brand.logo.favicon },
openGraph: {
siteName: brand.name,
title: brand.name,
type: "website",
images: [{ url: image, width: 1200, height: 630, alt: brand.name }],
},
twitter: { card: "summary_large_image", title: brand.name, images: [image] },
}
}
/**
* `brandMetadata` as a route's `head()`: the meta tags and the icon link. `base` is the deployment's public
* URL, so a link preview gets an absolute image address.
*/
export function brandHead(
metadata: BrandMetadata,
base?: string,
): {
meta: Record<string, string>[]
links: Record<string, string>[]
} {
const absolute = (url: string): string => (base ? new URL(url, base).href : url)
const image = metadata.openGraph.images[0]
return {
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: metadata.title },
{ name: "description", content: metadata.description },
{ name: "application-name", content: metadata.applicationName },
{ property: "og:site_name", content: metadata.openGraph.siteName },
{ property: "og:title", content: metadata.openGraph.title },
{ property: "og:type", content: metadata.openGraph.type },
...(image
? [
{ property: "og:image", content: absolute(image.url) },
{ property: "og:image:width", content: String(image.width) },
{ property: "og:image:height", content: String(image.height) },
{ property: "og:image:alt", content: image.alt },
]
: []),
{ name: "twitter:card", content: metadata.twitter.card },
{ name: "twitter:title", content: metadata.twitter.title },
...metadata.twitter.images.map((url) => ({ name: "twitter:image", content: absolute(url) })),
],
links: [{ rel: "icon", href: metadata.icons.icon }],
}
}
/** The labels the shell renders, translated by the root loader (src/server/page.ts `shellData`). */
export interface ShellLabels {
readonly signIn: string
readonly signOut: string
readonly account: string
readonly failed: string
readonly language: string
readonly languageEn: string
readonly languageLt: string
readonly theme: string
readonly themeDark: string
readonly themeLight: string
readonly themeSystem: string
readonly switchToLight: string
readonly switchToDark: string
readonly loading: string
readonly notFoundTitle: string
readonly notFoundBody: string
readonly forbiddenTitle: string
readonly forbiddenBody: string
}
/** The root route's loader data: everything the document and the page states render, as plain values. */
export interface ShellData {
readonly brand: ShellBrand
/** The way in, in the page's language (`authBrand` of `config/src/brand.ts`). */
readonly auth: AuthBrand
/** The deployment's public URL: what makes a share image's address absolute for a link preview. */
readonly publicUrl: string
readonly locale: string
readonly theme: Theme
/** The frame's layout as this browser left it (`app_sidebar`, `app_panel_width`). */
readonly sidebarOpen: boolean
readonly panelWidth?: number
readonly signedIn: boolean
readonly csrf?: string
readonly observe?: BrowserObserveSettings
readonly nonce?: string
/** The organization theme's stylesheet (BK12), when the platform gives one. */
readonly organizationTheme?: string
readonly accountHref: string
readonly labels: ShellLabels
/** The strings the kit's primitives and patterns render themselves, in the page's language. */
readonly ui: UiLabels
readonly help: HelpLabels
readonly privacy: PrivacyChoiceMessages
readonly boundary: BoundaryStrings
}
/** The class `system` means on this device, set before anything paints. The server cannot know it. */
const SYSTEM_THEME_SCRIPT = `document.documentElement.classList.toggle("light",matchMedia("(prefers-color-scheme: light)").matches)`
export interface DocumentProps {
readonly brand: ShellBrand
readonly locale: string
/** `light` puts the theme class on the root, the one element that carries it (spec §10.3). */
readonly theme: Theme
readonly signedIn: boolean
readonly csrf?: string
/** Set when the deployment turned observability on: the browser starts its SDK from this (src/observe). */
readonly observe?: BrowserObserveSettings
/** The request's script nonce: `system` needs a script in the head. */
readonly nonce?: string
readonly signIn: string
/** The sign-out control, already rendered with its label. */
readonly signOut: ReactNode
/** The theme and language controls, already rendered with their labels. */
readonly controls: ReactNode
readonly privacy: PrivacyChoiceMessages
/** The organization whose page this is: its colours, and the mark a test finds it by. */
readonly tenant?: { readonly style: Readonly<Record<string, string>>; readonly mark?: string }
/** The router's `<HeadContent />`: title, meta and the stylesheet links. */
readonly head?: ReactNode
/** The router's `<Scripts />`, last in the body. */
readonly scripts?: ReactNode
readonly children: ReactNode
}
export function Document({
brand,
locale,
theme,
signedIn,
csrf,
observe,
nonce,
signIn,
signOut,
controls,
privacy,
tenant,
head,
scripts,
children,
}: DocumentProps): ReactElement {
return (
<html
lang={locale}
className={theme === "light" ? "light" : undefined}
data-brand={brand.shortName}
suppressHydrationWarning={theme === "system"}
>
<head>
{theme === "system" ? (
<script
{...(nonce ? { nonce } : {})}
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: SYSTEM_THEME_SCRIPT }}
/>
) : null}
{head}
<style
data-brand-layer="app"
{...(nonce ? { nonce } : {})}
dangerouslySetInnerHTML={{ __html: brandLayerCss(brand.style) }}
/>
{csrf ? <meta name="csrf-token" content={csrf} /> : null}
{observe ? <meta {...observeMeta(observe)} /> : null}
</head>
<body {...(tenant?.mark ? { "data-tenant-brand": tenant.mark } : {})} style={tenant?.style as CSSProperties}>
<PrivacyChoices
messages={privacy}
footer={
<div data-slot="document-footer">
<PrivacyFooter />
</div>
}
>
<header className="app-shell-header">
<a href="/" className="app-brand-link">
<img className="app-logo app-logo-dark" src={brand.logo.dark} alt="" />
<img className="app-logo app-logo-light" src={brand.logo.light} alt="" />
{brand.name}
</a>
<div className="app-shell-controls">
{controls}
{signedIn ? signOut : <a href="/auth/sign-in">{signIn}</a>}
</div>
</header>
{children}
</PrivacyChoices>
{scripts}
</body>
</html>
)
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// What the frame leaves to the host: the sidebar's state and the right panel's width. The kit stores
// nothing; both are preference cookies, so the first paint has them (docs/reference/compliance/cookies-and-storage.md).
import { useState } from "react"
import type { ShellData } from "./document"
import { PANEL_WIDTH_COOKIE, preferenceCookie, SIDEBAR_COOKIE } from "./preference-cookies"
export interface FrameLayout {
readonly sidebarOpen: boolean
onSidebarOpenChange(open: boolean): void
readonly panelWidth?: number
onPanelWidthChange(width: number): void
}
export function useFrameLayout(shell: Pick<ShellData, "sidebarOpen" | "panelWidth">): FrameLayout {
const [sidebarOpen, setSidebarOpen] = useState(shell.sidebarOpen)
const [panelWidth, setPanelWidth] = useState(shell.panelWidth)
return {
sidebarOpen,
onSidebarOpenChange(open) {
setSidebarOpen(open)
// biome-ignore lint/suspicious/noDocumentCookie: a preference cookie of a few bytes; the Cookie Store API is not in every browser this app serves
document.cookie = preferenceCookie(SIDEBAR_COOKIE, open ? "open" : "closed")
},
...(panelWidth === undefined ? {} : { panelWidth }),
onPanelWidthChange(width) {
setPanelWidth(width)
// biome-ignore lint/suspicious/noDocumentCookie: as above
document.cookie = preferenceCookie(PANEL_WIDTH_COOKIE, String(width))
},
}
}
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// TanStack Router as the frame's `FrameRouter`, in this one place: the frame, the settings layout and a
// `ResourcePage` read the address and move through it, and none of them knows which router this is.
import type { FrameRouter } from "@conusai-kit/ui/frame"
import { useLocation, useRouter } from "@tanstack/react-router"
import { useEffect, useMemo } from "react"
export function useFrameRouter(): FrameRouter {
const router = useRouter()
const { pathname, searchStr } = useLocation()
return useMemo(
() => ({
pathname,
search: new URLSearchParams(searchStr),
async refresh() {
await router.invalidate()
},
navigate(to, options) {
void router.navigate({ href: to, ...(options?.replace ? { replace: true } : {}) })
},
}),
[router, pathname, searchStr],
)
}
/** Restore window scroll for same-document history entries. Reload uses native browser restoration. */
export function useFrameNavigationFocus(): void {
const router = useRouter()
useEffect(() => {
const positions = new Map<string, number>()
let restore: number | undefined
const capture = router.subscribe("onBeforeLoad", ({ fromLocation, hrefChanged }) => {
const key = fromLocation?.state.__TSR_key
if (!hrefChanged || !key) return
if (restore !== undefined) cancelAnimationFrame(restore)
positions.delete(key)
positions.set(key, window.scrollY)
if (positions.size > 100) {
const oldest = positions.keys().next().value
if (oldest) positions.delete(oldest)
}
})
const rendered = router.subscribe("onRendered", ({ pathChanged, toLocation }) => {
if (toLocation.hash) return
const key = toLocation.state.__TSR_key
const position = key ? positions.get(key) : undefined
if (position !== undefined) restore = requestAnimationFrame(() => window.scrollTo(0, position))
if (!pathChanged || document.activeElement?.closest('[data-slot="side-panel-content"]')) return
const heading = document.querySelector<HTMLElement>(".kit-frame-page h1")
if (heading) {
heading.tabIndex = -1
heading.focus({ preventScroll: true })
}
})
return () => {
capture()
rendered()
if (restore !== undefined) cancelAnimationFrame(restore)
}
}, [router])
}
/** What is served outside the router: a sign-in, a sign-out, an API read. */
const OUTSIDE_THE_ROUTER = /^\/(api|auth)(\/|$)/
/**
* A plain link of a page, in the frame, moves through the router: a full load would close the right panel, and the
* Agent's conversation with it. A press the browser keeps for itself stays the browser's: a new tab, a download.
*/
export function useFrameLinks(router: FrameRouter): void {
useEffect(() => {
const follow = (event: MouseEvent): void => {
if (event.defaultPrevented || event.button !== 0) return
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
const link = (event.target as Element | null)?.closest?.("a[href]")
if (!(link instanceof HTMLAnchorElement) || !link.closest('[data-slot="app-frame"]')) return
if (link.target !== "" || link.hasAttribute("download") || link.origin !== window.location.origin) return
if (OUTSIDE_THE_ROUTER.test(link.pathname)) return
if (link.pathname === location.pathname && link.search === location.search && link.hash) return
event.preventDefault()
router.navigate(`${link.pathname}${link.search}${link.hash}`)
}
document.addEventListener("click", follow)
return () => document.removeEventListener("click", follow)
}, [router])
}
+361
View File
@@ -0,0 +1,361 @@
/* SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 */
/* The app's stylesheet. Every colour is a shadcn role of the generated ConusAI theme
(@conusai-kit/brand/theme-dark.css: dark at :root, light under `.light`, each with its
color-scheme), and fonts and radii are brand tokens: this file declares no colour of its own, so a
white-label change is a change in config/brand.ts and nothing else (decision AT-D11). The app
brand and an organization's brand write the runtime roles (`--primary`, `--ring`,
`--sidebar-primary`, ...) through `themeStyle()`; this file only reads roles (brand-kit BK6). The
fonts are the self-hosted woff2 files of @conusai-kit/brand; no external stylesheet or font host
is referenced anywhere (self-hosted D20). */
@layer base, components;
@import "@conusai-kit/brand/tokens.css";
@import "@conusai-kit/brand/theme-dark.css";
@import "@conusai-kit/ui/styles.css";
@import "@conusai-kit/ui/primitives.css";
@import "@conusai-kit/ui/patterns.css";
@import "@conusai-kit/ui/assistant.css";
@import "@conusai-kit/ui/side-panel.css";
@import "@conusai-kit/ui/frame.css";
@import "@conusai-kit/ui/auth.css";
:root {
/* A separator between rows: the border role at a lower opacity, never a colour literal. */
--app-separator: color-mix(in oklab, var(--border) 45%, transparent);
}
/* The bare elements, for the few surfaces outside the kit's patterns (the sign-in pages, the shell's own
controls). In the `base` layer: an unlayered rule would beat every rule of the kit's `components` layer. */
@layer base {
a {
/* The ring, not the fill: the kit holds the ring at text contrast (4.5:1) on the page in both modes
(the brand teal is 1.8:1 as text on white, so the light ring is the teal ink). */
color: var(--ring);
}
h1,
h2,
h3 {
font-family: var(--brand-font-sans);
margin-block: 0 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: start;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--app-separator);
}
/* The kit's tables' weight, so a bare table reads as a DataTable does. */
th {
font-weight: 500;
}
button,
select,
input:not([type="checkbox"], [type="radio"], [type="hidden"]) {
font: inherit;
color: var(--secondary-foreground);
background: var(--secondary);
border: 1px solid var(--input);
border-radius: var(--brand-radius-button);
padding: 0.4rem 0.75rem;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: default;
}
button[aria-pressed="true"] {
border-color: var(--primary);
}
:focus-visible {
outline: 2px solid var(--ring);
outline-offset: 2px;
}
/* A page outside the frame: the frame's own main has the kit's spacing, and an auto margin would shrink it. */
main:not(.kit-frame-page) {
padding: 1.5rem;
max-width: 72rem;
margin: 0 auto;
}
/* A list of links is a list of targets: each is at least 24px high (WCAG 2.2, 2.5.8). */
main li > a {
display: inline-block;
min-height: 1.5rem;
}
}
/* The product UI keeps its app root when a lazy browser provider also loads the brand's site tokens.
Specificity, rather than stylesheet arrival order, keeps every rem-sized hit target steady during hydration. */
html:root {
font-size: var(--brand-app-font-size-root);
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
font-family: var(--brand-font-sans);
font-size: var(--brand-text-base);
line-height: var(--brand-leading-base);
}
.app-shell-header {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--app-separator);
background: var(--card);
}
/* The way in has its own controls and its own brand, so the document's header stands aside. */
body:has(.kit-auth, .kit-auth-page, [data-slot="app-state"]) .app-shell-header {
display: none;
}
.app-error {
border: 1px solid var(--app-separator);
border-radius: var(--brand-radius-panel, 8px);
padding: 0.75rem 1rem;
}
/* The app's logo and an organization's, one image per theme: the other is hidden by the theme class. */
.app-logo {
height: 1.75rem;
width: auto;
vertical-align: middle;
}
/* In the frame the mark fills the box the kit gives it. */
.kit-frame-brand .app-logo {
height: 100%;
}
.app-logo-light {
display: none;
}
.light .app-logo-light {
display: inline;
}
.light .app-logo-dark {
display: none;
}
.app-brand-link {
display: inline-flex;
gap: 0.5rem;
align-items: center;
color: var(--foreground);
font-weight: 600;
text-decoration: none;
}
/* The organization chooser is a list of whole-card links, with a visible keyboard focus. */
.app-chooser-list {
display: grid;
gap: 0.75rem;
margin-bottom: 1rem;
}
.app-chooser-footer {
display: grid;
gap: 1rem;
width: 100%;
padding-top: 1rem;
border-top: 1px solid var(--app-separator);
}
.app-chooser-preferences,
.app-chooser-account {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
}
.app-chooser-account {
justify-content: space-between;
}
[data-guide="home.organization"] {
position: relative;
transition:
border-color 150ms ease,
background-color 150ms ease;
}
[data-guide="home.organization"]:hover,
[data-guide="home.organization"]:focus-within {
border-color: var(--ring);
background: var(--accent);
}
[data-guide="home.organization"] h2 a {
color: var(--foreground);
text-decoration: none;
}
[data-guide="home.organization"] h2 a::after {
content: "";
position: absolute;
inset: 0;
}
@media (prefers-reduced-motion: reduce) {
[data-guide="home.organization"] {
transition: none;
}
}
/* The shell's controls, the form controls the pages use, and the states a page can be in. Brand
tokens only: a white-label change is still a change in config/brand.ts and nothing else. */
.app-shell-controls {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.app-control {
display: inline-flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.kit-field {
margin-block-end: 0.75rem;
}
.app-link-field {
width: 100%;
box-sizing: border-box;
font-family: var(--brand-font-mono);
}
/*
* Outside the frame, in the page's flow and held at the foot by `sticky`: the page gains the choices' height, so
* it can always be scrolled past them. Fixed, they sat over the sign-in form on a page too short to scroll.
* The scroll padding brings a focused control out from under them (WCAG 2.2, 2.4.11).
*/
:root:has(.app-consent) {
--kit-bottom-inset: min(60dvh, 30rem);
scroll-padding-block-end: var(--kit-bottom-inset);
}
.app-consent {
position: sticky;
bottom: 1rem;
z-index: 20;
box-sizing: border-box;
width: min(42rem, 100% - 2rem);
max-height: calc(100dvh - 2rem);
overflow-y: auto;
margin-block-start: 1rem;
margin-inline: auto;
padding: 1rem;
border: 1px solid var(--border);
border-radius: var(--brand-radius-panel);
background: var(--background);
color: var(--foreground);
}
/* The frame reserves this fixed notice's clearance in its page and panel scroll regions.
Keeping it inside the viewport avoids shortening the sticky panel's containing block. */
body:has([data-slot="app-frame"]) .app-consent {
position: fixed;
z-index: 45;
inset-inline-end: 1rem;
margin: 0;
}
/* Beside the frame's sidebar, whose foot holds the person's menu: the choices must not sit over it. */
@media (min-width: 48rem) {
body:has([data-slot="app-frame"]) .app-consent {
width: min(64rem, 100% - 19rem);
margin-inline: 0;
}
}
/* A section's heading on the type scale, as the kit's are, not the browser's 1.5em. */
.app-consent h2 {
margin-block: 0 0.25rem;
font-size: var(--brand-text-base);
font-weight: 600;
line-height: var(--brand-leading-base);
}
.app-consent-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-block-start: 1rem;
}
.app-consent-choice {
flex: 1 1 12rem;
min-height: 2.75rem;
font-weight: 600;
}
/* On a wide screen the choices stand beside the words, which keeps them low enough to leave a form clear. */
@media (min-width: 60rem) {
.app-consent {
display: grid;
grid-template-columns: 1fr 16rem;
column-gap: 1.5rem;
width: min(64rem, 100% - 2rem);
padding-block: 0.625rem;
}
.app-consent > :not(.app-consent-actions) {
grid-column: 1;
margin-block: 0 0.25rem;
}
.app-consent-actions {
grid-column: 2;
grid-row: 1 / span 3;
align-content: center;
margin-block-start: 0;
}
}
.app-privacy-footer {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
padding: 1rem;
}
.kit-frame-footer .app-privacy-footer {
justify-content: flex-start;
padding: 0;
}
/* The shell has a stable SSR tree even for root not-found outcomes. The rendered frame owns its
controls when present; the document keeps them on recovery/sign-in pages without route-status branches. */
body:has(.kit-frame) .app-shell-header,
body:has(.kit-frame) [data-slot="document-footer"] {
display: none;
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// Help in the right panel: the frame's one Help button, whatever modules this app enabled. Each section is one
// line `kit template:prune --without` can delete, and with neither left there is no button.
import { moduleSelected } from "@app/modules/inclusion"
import { GuidesPanel } from "@conusai-kit/guides/panel" // kit:guides
import { supportSection } from "@conusai-kit/support/panel" // kit:support
import type { ClientHostContext } from "@conusai-kit/ui"
import { type HelpSection, helpOccupant, type PanelOccupant } from "@conusai-kit/ui/patterns"
export interface HelpLabels {
readonly title: string
readonly sections: string
readonly guides: string
readonly support: string
}
export function helpFor(
context: ClientHostContext,
labels: HelpLabels,
): { readonly any: boolean; occupant(section?: string): PanelOccupant } {
const sections: HelpSection[] = []
// kit:guides:begin
if (moduleSelected("guides"))
sections.push({ id: "guides", label: labels.guides, render: () => <GuidesPanel locale={context.locale} /> })
// kit:guides:end
if (moduleSelected("support")) sections.push(supportSection({ label: labels.support, context })) // kit:support
return {
any: sections.length > 0,
occupant: (section) =>
helpOccupant({ title: labels.title, label: labels.sections, sections, ...(section ? { section } : {}) }),
}
}
+13
View File
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The document says when it is interactive: `data-hydrated="true"` on <html>, set once React has hydrated the
// page. The server never renders it, so its presence means a press reaches a handler. A press before that is
// lost (a tab stays unselected, a checkbox ignores Space), which is why browser tests wait for it
// (`ready(page)` in e2e/support/app.ts) rather than retrying their clicks.
import { useEffect } from "react"
export function useHydratedSignal(): void {
useEffect(() => {
document.documentElement.dataset.hydrated = "true"
}, [])
}
+87
View File
@@ -0,0 +1,87 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The button that accepts an invitation (spec §3.1). Accepting is a POST, never the page load: a link that is
// opened by a mail scanner, a preview or a prefetch must not use up a token that is good once. Accepted, the
// person goes straight to the organization's workspace the kernel names. A refusal is worded per state -- the
// kernel gives each a code of its own for the person holding the invitee's token, and one NotFound for
// everybody else (kit-tenancy) -- and none of them says more than the person already knows.
import { DataError } from "@conusai-kit/ui/data"
import { Alert, AlertDescription, Button } from "@conusai-kit/ui/primitives"
import { type ReactElement, useState } from "react"
import { call } from "./client"
import { adoptOrganizationLanguage } from "./organization-language"
export interface InviteAcceptLabels {
readonly accept: string
readonly accepted: string
readonly invalid: string
readonly used: string
readonly revoked: string
readonly expired: string
readonly member: string
readonly failed: string
}
/** Which message a refusal gets: the problem's `code`, which the kernel keeps stable. */
export function inviteErrorKey(
code: string,
): keyof Pick<InviteAcceptLabels, "invalid" | "used" | "revoked" | "expired" | "member" | "failed"> {
switch (code) {
case "not_found":
return "invalid"
case "invitation_used":
return "used"
case "invitation_revoked":
return "revoked"
case "invitation_expired":
return "expired"
case "already_member":
return "member"
default:
return "failed"
}
}
export function InviteAccept({
org,
token,
labels,
}: {
org: string
token: string
labels: InviteAcceptLabels
}): ReactElement {
const [busy, setBusy] = useState(false)
const [joined, setJoined] = useState(false)
const [problem, setProblem] = useState<string | undefined>(undefined)
const accept = async (): Promise<void> => {
setBusy(true)
setProblem(undefined)
try {
const { workspace } = await call<{ workspace: string }>("tenancy.invitation.accept", { org, token })
await adoptOrganizationLanguage(org)
setJoined(true)
window.location.assign(`/${encodeURIComponent(org)}/${encodeURIComponent(workspace)}`)
} catch (error) {
setProblem(labels[inviteErrorKey(error instanceof DataError ? (error.problem.code ?? "") : "")])
setBusy(false)
}
}
if (joined) return <p role="status">{labels.accepted}</p>
return (
<>
<Button disabled={busy} onClick={accept}>
{labels.accept}
</Button>
{problem ? (
<Alert variant="destructive" role="alert">
<AlertDescription>{problem}</AlertDescription>
</Alert>
) : null}
</>
)
}
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What shows while a page that has real work to do is being fetched: a route's `pendingComponent`. It must
// not wait for anything itself, so its label comes from the root route's loader data (`ShellData`), which
// is already in the page.
import { PageSkeleton } from "@conusai-kit/ui/patterns"
import type { ReactElement } from "react"
/** A pattern and not `StateView`: every route that shows it is inside the frame, whose <main> this is. */
export function LoadingPage({ label }: { readonly label: string }): ReactElement {
return <PageSkeleton label={label} />
}
// Where it is mounted, and where it is not, is deliberate. Every navigation is a full server render (F10)
// and a loader finishes before the response starts, so the status a loader set -- a 302 to sign in, a 404
// for another tenant's slug, a 403 for a member without the permission -- is the response's status. A
// pending component shows only in the browser, for a loader that runs there; the server never streams one
// in front of a status. It is mounted with `pendingComponent: RoutePending` (src/ui/route-pending.tsx) on the
// routes whose loaders are slow: the members, the roles, the invitations and the audit logs.
+418
View File
@@ -0,0 +1,418 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// This app's own message catalogue, merged over the kit's (spec §10.4). Every string a page shows
// is a key here, in English and in Lithuanian, so a locale is a catalogue and not a code change.
//
// Every module's messages -- the kernel's included -- come from its manifest (`i18n: { en, lt }`) and are
// merged by kit-i18n from the registry, so a module's strings reach the pages with no edit outside the
// module. What is here is the app shell's own: the `app.*` messages.
import { appModules } from "@app/modules"
import { buildRegistry } from "@conusai-kit/core"
import { type Catalogues, moduleCatalogues } from "@conusai-kit/i18n"
const en = {
consent: {
title: "Privacy choices",
body: "Accept optional analytics, or reject them. Accepting and rejecting are equally easy. Session replay is separate and stays off unless you select it.",
replay: "Also allow session replay, with text, inputs and media masked",
accept: "Accept analytics",
reject: "Reject analytics and replay",
reopen: "Privacy choices",
gpc: "Global Privacy Control is on: analytics and replay stay off.",
},
app: {
signIn: "Sign in",
signOut: "Sign out",
signedOut: "You are signed out.",
signedOutBody: "Close this tab, or sign in again.",
home: "Home",
organizations: "Organizations",
chooseOrganization: "Choose an organization to continue.",
noOrganizations: "You are not a member of an organization yet.",
askForInvitation: "Ask your organization's administrator for an invitation.",
createOrganization: "Create an organization",
name: "Name",
slug: "Short name",
create: "Create",
cancel: "Cancel",
role: "Role",
email: "E-mail address",
invite: "Invite",
remove: "Remove",
save: "Save",
workspaces: "Workspaces",
chooseWorkspace: "Choose a workspace to view its projects.",
projects: "Projects",
members: "Members",
invitations: "Invitations",
settings: "Settings",
audit: "Audit",
auditEmpty: "No audit events for this organization yet.",
action: "Action",
outcome: "Outcome",
when: "When",
resource: "Resource",
status: "Status",
failed: "That did not work: {detail}",
open: "Open",
workspace: "Workspace",
project: "Project",
},
shell: {
organization: "Organization",
language: "Language",
languageEn: "English",
languageLt: "Lietuvių",
theme: "Theme",
themeDark: "Dark",
themeLight: "Light",
themeSystem: "System",
switchToLight: "Switch to light theme",
switchToDark: "Switch to dark theme",
account: "Signed in as {name}",
sections: "Settings sections",
helpSections: "Help sections",
guides: "Guides",
support: "Support",
general: "General",
organizationTitle: "Organization",
defaultLanguage: "Default language",
defaultLanguageHint: "New members start in this language.",
saved: "Saved",
projects: "Projects",
},
onboarding: {
title: "Create your organization",
description: "Your organization holds members, workspaces and projects. You will be its owner.",
name: "Organization name",
nameHelp: "Usually your company or team. You can change it later.",
address: "Address",
addressHelp: "Lowercase letters, numbers and hyphens. Used in links.",
continue: "Continue",
required: "Fill this in.",
addressInvalid: "Use lowercase letters, numbers and single hyphens.",
addressTaken: "This address is taken. Choose another.",
failed: "The organization could not be created. Try again.",
workspaceName: "General",
workspaceSlug: "general",
},
signin: {
title: "Sign in",
createAccount: "Create an account",
devTitle: "Development sign-in",
devHelp:
"This deployment has no identity provider, so any e-mail address signs in. It exists for development and is refused in production.",
devSubmit: "Sign in",
notConfigured:
"Sign-in is not configured for this deployment. Ask the person who runs it to set an identity provider.",
error: {
sessionExpired: "Your session has ended. Sign in again.",
noTransaction: "That sign-in was not started in this browser, or it took too long. Start again.",
denied: "The identity provider did not let you in. Try again, or use another account.",
provider: "The identity provider could not complete the sign-in. Try again in a moment.",
failed: "Signing in did not work. Try again.",
},
// kit:convex-better-auth:begin
account: {
name: "Name",
password: "Password",
passwordHelp: "At least {min} characters.",
signIn: "Sign in",
signUp: "Create the account",
toSignUp: "No account yet? Create one",
toSignIn: "Already have an account? Sign in",
signUpTitle: "Create an account",
signUpIntro: "Create an account with your e-mail address and a password.",
error: {
invalid: "That e-mail address and password do not match an account.",
exists: "An account with that e-mail address exists already. Sign in instead.",
disabled: "This account is disabled. Ask the people who run this app.",
rateLimited: "Too many attempts. Wait a moment and try again.",
unavailable: "Signing in is not possible right now. Try again in a moment.",
failed: "That did not work. Check the form and try again.",
},
},
// kit:convex-better-auth:end
},
invite: {
title: "You are invited",
lead: "{email} is invited to join {org} as {role}.",
leadNoRole: "{email} is invited to join {org}.",
expires: "The link works once, until {date}.",
createAccount: "Create your account",
haveAccount: "I have an account: sign in",
signInFirst: "Sign in with the e-mail address the invitation was sent to, then come back to this page.",
signedInAs: "You are signed in as {email}.",
otherAccount: "This invitation is for {email}. Sign in with that address to accept it.",
accept: "Accept the invitation",
accepted: "You joined {org}.",
switchAccount: "Sign in with another account",
error: {
unknown: "This invitation link is not valid. Check that you have all of it, or ask for a new one.",
invalid:
"This invitation is not valid for the account you are signed in with. Sign in with the address it was sent to, or ask for a new one.",
used: "This invitation has already been used. Sign in to open the organization.",
revoked: "This invitation was revoked. Ask an administrator of the organization for a new one.",
expired: "This invitation has expired. Ask an administrator of the organization for a new one.",
member: "You are already a member of this organization.",
failed: "The invitation could not be accepted. Try again.",
},
},
boundary: {
errorTitle: "Something went wrong",
errorBody: "The page could not be shown. Try again. If it keeps happening, tell the person who runs this app.",
errorCode: "Error code: {digest}",
retry: "Try again",
notFoundTitle: "Page not found",
notFoundBody: "There is nothing at this address, or you do not have access to it.",
forbiddenTitle: "You do not have access",
forbiddenBody: "Your role does not allow this page. Ask an administrator of the organization.",
home: "Back to the start",
loading: "Loading",
},
workspace: {
title: "Workspace {name}",
list: "Workspaces",
none: "There are no workspaces you can see.",
create: "Create a workspace",
visibility: "Visibility",
visibilityOrg: "Everyone in the organization",
visibilityRestricted: "Invited members only",
default: "Default",
projects: "Projects",
overview: "Workspace overview",
group: "Workspace",
projectGroup: "Project",
chooseProject: "Choose a project",
switchProject: "Switch project",
organizationSettings: "Organization settings",
nextAction: "Open a project to continue its work, or choose a workspace tool from the sidebar.",
projectCount: "{count, plural, one {# project} other {# projects}}",
noProjects: "There are no projects in this workspace yet.",
createProject: "Create a project",
module: "Module",
},
project: {
title: "Project {name}",
overview: "Project overview",
work: "Work in this project",
noSections: "This project has no sections available to you.",
archived: "This project is archived.",
module: "Created by the module {module}",
},
} as const
const lt = {
consent: {
title: "Privatumo pasirinkimai",
body: "Priimkite neprivalomą analitiką arba ją atmeskite. Priimti ir atmesti vienodai paprasta. Seanso atkūrimas yra atskiras ir išlieka išjungtas, jei jo nepasirinksite.",
replay: "Taip pat leisti seanso atkūrimą, paslėpus tekstą, įvestis ir vaizdinę medžiagą",
accept: "Priimti analitiką",
reject: "Atmesti analitiką ir atkūrimą",
reopen: "Privatumo pasirinkimai",
gpc: "Įjungtas Global Privacy Control: analitika ir atkūrimas išlieka išjungti.",
},
app: {
signIn: "Prisijungti",
signOut: "Atsijungti",
signedOut: "Jūs atsijungėte.",
signedOutBody: "Užverkite šią kortelę arba prisijunkite iš naujo.",
home: "Pradžia",
organizations: "Organizacijos",
chooseOrganization: "Pasirinkite organizaciją, kad tęstumėte.",
noOrganizations: "Kol kas nesate jokios organizacijos narys.",
askForInvitation: "Paprašykite savo organizacijos administratoriaus pakvietimo.",
createOrganization: "Sukurti organizaciją",
name: "Pavadinimas",
slug: "Trumpasis pavadinimas",
create: "Sukurti",
cancel: "Atsisakyti",
role: "Rolė",
email: "El. pašto adresas",
invite: "Pakviesti",
remove: "Pašalinti",
save: "Išsaugoti",
workspaces: "Darbo erdvės",
chooseWorkspace: "Pasirinkite darbo erdvę, kad peržiūrėtumėte jos projektus.",
projects: "Projektai",
members: "Nariai",
invitations: "Pakvietimai",
settings: "Nustatymai",
audit: "Auditas",
auditEmpty: "Šiai organizacijai audito įrašų dar nėra.",
action: "Veiksmas",
outcome: "Rezultatas",
when: "Kada",
resource: "Objektas",
status: "Būsena",
failed: "Nepavyko: {detail}",
open: "Atverti",
workspace: "Darbo erdvė",
project: "Projektas",
},
shell: {
organization: "Organizacija",
language: "Kalba",
languageEn: "English",
languageLt: "Lietuvių",
theme: "Tema",
themeDark: "Tamsi",
themeLight: "Šviesi",
themeSystem: "Sistemos",
switchToLight: "Perjungti į šviesią temą",
switchToDark: "Perjungti į tamsią temą",
account: "Prisijungęs: {name}",
sections: "Nustatymų skyriai",
helpSections: "Pagalbos skyriai",
guides: "Gidai",
support: "Pagalba",
general: "Bendrieji",
organizationTitle: "Organizacija",
defaultLanguage: "Numatytoji kalba",
defaultLanguageHint: "Nauji nariai pradeda šia kalba.",
saved: "Išsaugota",
projects: "Projektai",
},
onboarding: {
title: "Sukurkite savo organizaciją",
description: "Organizacijoje yra nariai, darbo erdvės ir projektai. Jūsų vaidmuo bus „Savininkas“.",
name: "Organizacijos pavadinimas",
nameHelp: "Dažniausiai tai jūsų įmonė arba komanda. Vėliau galėsite pakeisti.",
address: "Adresas",
addressHelp: "Mažosios raidės, skaičiai ir brūkšneliai. Naudojama nuorodose.",
continue: "Tęsti",
required: "Užpildykite šį lauką.",
addressInvalid: "Naudokite mažąsias raides, skaičius ir pavienius brūkšnelius.",
addressTaken: "Šis adresas užimtas. Pasirinkite kitą.",
failed: "Organizacijos sukurti nepavyko. Bandykite dar kartą.",
workspaceName: "Bendra",
workspaceSlug: "bendra",
},
signin: {
title: "Prisijungimas",
createAccount: "Sukurti paskyrą",
devTitle: "Prisijungimas kūrimo aplinkoje",
devHelp:
"Šioje diegtyje nėra tapatybės teikėjo, todėl prisijungti galima su bet kuriuo el. pašto adresu. Tai skirta kūrimui, o produkcinėje aplinkoje neveikia.",
devSubmit: "Prisijungti",
notConfigured:
"Prisijungimas šioje diegtyje nesukonfigūruotas. Paprašykite ją administruojančio asmens nurodyti tapatybės teikėją.",
error: {
sessionExpired: "Jūsų sesija baigėsi. Prisijunkite iš naujo.",
noTransaction: "Šis prisijungimas nebuvo pradėtas šioje naršyklėje arba užtruko per ilgai. Pradėkite iš naujo.",
denied: "Tapatybės teikėjas jūsų neįleido. Bandykite dar kartą arba naudokite kitą paskyrą.",
provider: "Tapatybės teikėjas nesugebėjo užbaigti prisijungimo. Bandykite dar kartą už kelių akimirkų.",
failed: "Prisijungti nepavyko. Bandykite dar kartą.",
},
// kit:convex-better-auth:begin
account: {
name: "Vardas",
password: "Slaptažodis",
passwordHelp: "Ne mažiau kaip {min} simbolių.",
signIn: "Prisijungti",
signUp: "Sukurti paskyrą",
toSignUp: "Dar neturite paskyros? Sukurkite",
toSignIn: "Jau turite paskyrą? Prisijunkite",
signUpTitle: "Paskyros kūrimas",
signUpIntro: "Sukurkite paskyrą su savo el. pašto adresu ir slaptažodžiu.",
error: {
invalid: "Toks el. pašto adresas ir slaptažodis neatitinka jokios paskyros.",
exists: "Paskyra su šiuo el. pašto adresu jau yra. Prisijunkite.",
disabled: "Ši paskyra išjungta. Kreipkitės į šios programos administratorius.",
rateLimited: "Per daug bandymų. Palaukite ir bandykite dar kartą.",
unavailable: "Šiuo metu prisijungti neįmanoma. Bandykite dar kartą už kelių akimirkų.",
failed: "Nepavyko. Patikrinkite formą ir bandykite dar kartą.",
},
},
// kit:convex-better-auth:end
},
invite: {
title: "Esate pakviesti",
lead: "Adresas {email} pakviestas prisijungti prie „{org}“ su role „{role}“.",
leadNoRole: "Adresas {email} pakviestas prisijungti prie „{org}“.",
expires: "Nuoroda veikia vieną kartą, iki {date}.",
createAccount: "Susikurti paskyrą",
haveAccount: "Jau turiu paskyrą: prisijungti",
signInFirst: "Prisijunkite su el. pašto adresu, kuriuo gavote kvietimą, ir grįžkite į šį puslapį.",
signedInAs: "Esate prisijungę kaip {email}.",
otherAccount: "Šis kvietimas skirtas adresui {email}. Prisijunkite šiuo adresu, kad jį priimtumėte.",
accept: "Priimti kvietimą",
accepted: "Prisijungėte prie „{org}“.",
switchAccount: "Prisijungti kita paskyra",
error: {
unknown: "Ši kvietimo nuoroda negalioja. Patikrinkite, ar nukopijavote ją visą, arba paprašykite naujos.",
invalid:
"Šis kvietimas netinka paskyrai, su kuria esate prisijungę. Prisijunkite su adresu, kuriuo jį gavote, arba paprašykite naujo.",
used: "Šis kvietimas jau panaudotas. Prisijunkite, kad atvertumėte organizaciją.",
revoked: "Šis kvietimas atšauktas. Paprašykite organizacijos administratoriaus naujo.",
expired: "Šio kvietimo galiojimas baigėsi. Paprašykite organizacijos administratoriaus naujo.",
member: "Jau esate šios organizacijos narys.",
failed: "Kvietimo priimti nepavyko. Bandykite dar kartą.",
},
},
boundary: {
errorTitle: "Kažkas nepavyko",
errorBody:
"Puslapio parodyti nepavyko. Bandykite dar kartą. Jei klaida kartosis, praneškite šią programą administruojančiam asmeniui.",
errorCode: "Klaidos kodas: {digest}",
retry: "Bandyti dar kartą",
notFoundTitle: "Puslapis nerastas",
notFoundBody: "Šiuo adresu nieko nėra arba neturite prieigos.",
forbiddenTitle: "Neturite prieigos",
forbiddenBody: "Jūsų rolė šio puslapio neleidžia. Kreipkitės į organizacijos administratorių.",
home: "Grįžti į pradžią",
loading: "Kraunama",
},
workspace: {
title: "Darbo erdvė „{name}“",
list: "Darbo erdvės",
none: "Matomų darbo erdvių nėra.",
create: "Sukurti darbo erdvę",
visibility: "Matomumas",
visibilityOrg: "Visi organizacijos nariai",
visibilityRestricted: "Tik pakviesti nariai",
default: "Numatytoji",
projects: "Projektai",
overview: "Darbo erdvės apžvalga",
group: "Darbo erdvė",
projectGroup: "Projektas",
chooseProject: "Pasirinkite projektą",
switchProject: "Perjungti projektą",
organizationSettings: "Organizacijos nustatymai",
nextAction: "Atverkite projektą ir tęskite darbą arba šoninėje juostoje pasirinkite darbo erdvės įrankį.",
projectCount: "{count, plural, one {# projektas} few {# projektai} other {# projektų}}",
noProjects: "Šioje darbo erdvėje projektų dar nėra.",
createProject: "Sukurti projektą",
module: "Modulis",
},
project: {
title: "Projektas „{name}“",
overview: "Projekto apžvalga",
work: "Darbas šiame projekte",
noSections: "Šiame projekte jums prieinamų skyrių nėra.",
archived: "Šis projektas suarchyvuotas.",
module: "Sukūrė modulis: {module}",
},
} as const
/** `tenancy.nav.members` is one key with a dot inside the module's own part, so it is flattened. */
const flatten = (input: Record<string, unknown>, prefix = ""): Record<string, string> => {
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(input)) {
const name = prefix ? `${prefix}.${key}` : key
if (typeof value === "string") out[name] = value
else Object.assign(out, flatten(value as Record<string, unknown>, name))
}
return out
}
const ownCatalogues = (source: Record<string, Record<string, unknown>>): Record<string, Record<string, string>> =>
Object.fromEntries(Object.entries(source).map(([module, messages]) => [module, flatten(messages)]))
/** The kit's built-in messages and every module's, from the registry this app boots with. */
const fromModules = moduleCatalogues(buildRegistry([...appModules]))
export const appCatalogues: Catalogues = {
en: { ...fromModules.en, ...ownCatalogues(en) },
lt: { ...fromModules.lt, ...ownCatalogues(lt) },
}
+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// Onboarding's one card: the organization's name and its address. The address follows the name until the person
// edits it, in the change handler and never in an effect: a stage page froze when an effect re-ran after a taken
// address. The first workspace comes with the organization, named in the deployment's language.
import { DataError } from "@conusai-kit/ui/data"
import {
Alert,
AlertDescription,
Button,
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
Input,
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
Spinner,
} from "@conusai-kit/ui/primitives"
import { type FormEvent, type ReactElement, useState } from "react"
import { call } from "./client"
export interface OnboardingLabels {
readonly name: string
readonly nameHelp: string
readonly address: string
readonly addressHelp: string
readonly addressPrefix: string
readonly continue: string
readonly required: string
readonly addressInvalid: string
readonly addressTaken: string
readonly failed: string
}
/** The same rule as kit-tenancy's `slugSchema`: 1 to 48 lower-case letters, digits and inner dashes. */
const ADDRESS = /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/
/** An address from a name: without its accents, lower-case, dashes for the rest. */
export function addressFrom(name: string): string {
return name
.normalize("NFD")
.replace(/\p{Mark}/gu, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.slice(0, 48)
.replace(/-+$/, "")
}
interface Failure {
readonly field?: "name" | "address"
readonly message: string
}
export function OnboardingForm({
labels,
workspace,
}: {
readonly labels: OnboardingLabels
/** The first workspace: its name in the deployment's language, and its address. */
readonly workspace: { readonly name: string; readonly slug: string }
}): ReactElement {
const [name, setName] = useState("")
const [address, setAddress] = useState("")
const [edited, setEdited] = useState(false)
const [pending, setPending] = useState(false)
const [failure, setFailure] = useState<Failure | null>(null)
const submit = async (event: FormEvent): Promise<void> => {
event.preventDefault()
if (name.trim() === "") return setFailure({ field: "name", message: labels.required })
if (!ADDRESS.test(address)) return setFailure({ field: "address", message: labels.addressInvalid })
setPending(true)
setFailure(null)
try {
const created = (await call("tenancy.org.create", { name: name.trim(), slug: address, workspace })) as {
slug: string
workspaceSlug: string
}
window.location.assign(`/${encodeURIComponent(created.slug)}/${encodeURIComponent(created.workspaceSlug)}`)
} catch (error) {
setPending(false)
const taken = error instanceof DataError && error.status === 409
setFailure(taken ? { field: "address", message: labels.addressTaken } : { message: labels.failed })
}
}
return (
<form onSubmit={(event) => void submit(event)} noValidate>
{failure && !failure.field ? (
<Alert variant="destructive">
<AlertDescription>{failure.message}</AlertDescription>
</Alert>
) : null}
<FieldGroup>
<Field data-invalid={failure?.field === "name"}>
<FieldLabel>{labels.name}</FieldLabel>
<Input
name="name"
autoComplete="organization"
autoFocus
maxLength={120}
value={name}
disabled={pending}
onChange={(event) => {
setName(event.currentTarget.value)
if (!edited) setAddress(addressFrom(event.currentTarget.value))
}}
/>
<FieldDescription>{labels.nameHelp}</FieldDescription>
{failure?.field === "name" ? <FieldError>{failure.message}</FieldError> : null}
</Field>
<Field data-invalid={failure?.field === "address"}>
<FieldLabel>{labels.address}</FieldLabel>
<InputGroup>
<InputGroupAddon>
<InputGroupText>{labels.addressPrefix}</InputGroupText>
</InputGroupAddon>
<InputGroupInput
name="slug"
autoComplete="off"
spellCheck={false}
maxLength={48}
value={address}
disabled={pending}
onChange={(event) => {
setEdited(true)
setAddress(event.currentTarget.value)
if (failure?.field === "address") setFailure(null)
}}
/>
</InputGroup>
<FieldDescription>{labels.addressHelp}</FieldDescription>
{failure?.field === "address" ? <FieldError>{failure.message}</FieldError> : null}
</Field>
</FieldGroup>
<Button type="submit" disabled={pending}>
{pending ? <Spinner aria-hidden="true" /> : null}
{labels.continue}
</Button>
</form>
)
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// An organization's default language: `settings.defaultLocale` on its row, through `tenancy.org.update`, so
// nothing but the settings page writes it. A person's language is their own (a signed-in person's row always
// holds one), so the default takes effect when someone joins: `adoptOrganizationLanguage` runs once, as an
// invitation is accepted, and leaves alone anyone who has chosen a language in this browser.
import { call } from "./client"
import { LOCALE_COOKIE, preferenceCookie } from "./preference-cookies"
export const DEFAULT_LOCALE_SETTING = "defaultLocale"
/** What the settings form shows, and the version it saves over. */
export interface OrganizationFields {
readonly name: string
readonly defaultLocale: string
readonly version: number
}
export interface OrganizationLabels {
readonly title: string
readonly name: string
readonly language: string
readonly languageHint: string
readonly languages: readonly { readonly value: string; readonly label: string }[]
readonly save: string
readonly saved: string
}
export async function adoptOrganizationLanguage(org: string): Promise<void> {
if (document.cookie.split("; ").some((cookie) => cookie.startsWith(`${LOCALE_COOKIE}=`))) return
try {
const { settings } = (await call("tenancy.org.get", { org })) as { settings: Record<string, unknown> }
const locale = settings[DEFAULT_LOCALE_SETTING]
if (typeof locale !== "string") return
await call("tenancy.me.update", { locale })
// biome-ignore lint/suspicious/noDocumentCookie: a preference cookie of a few bytes; the Cookie Store API is not in every browser this app serves
document.cookie = preferenceCookie(LOCALE_COOKIE, locale)
} catch {
// The person has joined; the language is theirs to set from the menu.
}
}
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The organization's name and default language: one section, saved through `tenancy.org.update`. The page
// is read again after a save, so the frame shows the new name and the next save carries the new version.
import { SelectField, SettingsSection, TextField } from "@conusai-kit/ui/patterns"
import { useRouter } from "@tanstack/react-router"
import type { ReactElement } from "react"
import { call } from "./client"
import { DEFAULT_LOCALE_SETTING, type OrganizationFields, type OrganizationLabels } from "./organization-language"
export function OrganizationSettings({
org,
organization,
labels,
}: {
org: string
organization: OrganizationFields
labels: OrganizationLabels
}): ReactElement {
const router = useRouter()
return (
<SettingsSection
title={labels.title}
saveLabel={labels.save}
savedLabel={labels.saved}
onSave={async ({ name, language }) => {
// The row's settings hold what other modules keep there, so they are read here and written back whole.
const { settings } = (await call("tenancy.org.get", { org })) as { settings: Record<string, unknown> }
await call("tenancy.org.update", {
org,
name,
settings: { ...settings, [DEFAULT_LOCALE_SETTING]: language },
expectedVersion: organization.version,
})
await router.invalidate()
}}
>
<TextField name="name" label={labels.name} required maxLength={200} defaultValue={organization.name} />
<SelectField
name="language"
label={labels.language}
description={labels.languageHint}
defaultValue={organization.defaultLocale}
options={[...labels.languages]}
/>
</SettingsSection>
)
}
+72
View File
@@ -0,0 +1,72 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// A matched server-rendered page must hydrate without suspending on its own code:
// replacing that markup with a lazy fallback can discard a person's first click.
// Prepare only the initial matched module; later navigations keep loading on demand.
import { modulePages, workspacePages } from "@app/modules/pages"
import { type ComponentType, lazy } from "react"
type PageLoader<Props> = () => Promise<{ readonly View: ComponentType<Props> }>
export function createPageViews<Props>(pages: Readonly<Record<string, PageLoader<Props>>>) {
const entries = new Map<string, { component?: ComponentType<Props>; pending?: Promise<ComponentType<Props>> }>()
const has = (id: string): boolean => Object.hasOwn(pages, id)
const entryOf = (id: string) => {
if (!has(id)) throw new Error(`no page for module ${id}`)
let entry = entries.get(id)
if (!entry) {
entry = {}
entries.set(id, entry)
}
return entry
}
const prepare = (id: string): Promise<ComponentType<Props>> => {
const entry = entryOf(id)
const load = pages[id]
if (!load) throw new Error(`no page for module ${id}`)
entry.pending ??= load().then(({ View }) => {
// When prepared before the first render, no React.lazy promise sits
// between the existing server markup and its first client render.
entry.component ??= View
return View
})
return entry.pending
}
return {
has,
prepare,
viewOf(id: string): ComponentType<Props> {
const entry = entryOf(id)
entry.component ??= lazy(async () => ({ default: await prepare(id) }))
return entry.component
},
}
}
export const projectPageViews = createPageViews(modulePages)
export const workspacePageViews = createPageViews(workspacePages)
interface HydratedMatch {
readonly routeId: string
readonly status: string
readonly loaderData?: unknown
}
export async function prepareInitialModuleViews(matches: readonly HydratedMatch[]): Promise<void> {
// A failed view import belongs to its route's error boundary. The router must
// still mount; rejecting the root hydration promise would remove the whole shell.
await Promise.allSettled(
matches.map(async ({ routeId, status, loaderData }) => {
if (status !== "success" || !loaderData || typeof loaderData !== "object") return
const module = "module" in loaderData ? loaderData.module : undefined
if (typeof module !== "string") return
const pages =
routeId === "/$org/$workspace/modules/$module/$"
? workspacePageViews
: routeId === "/$org/$workspace/$project/$module/$"
? projectPageViews
: undefined
if (pages?.has(module)) await pages.prepare(module)
}),
)
}

Some files were not shown because too many files have changed in this diff Show More