ConusAI vendor v0.1.0-dev.20261027+9b1131fb
ci / gate (push) Waiting to run

This commit is contained in:
ConusAI vendor v0.1.0-dev.20261027+9b1131fb
2026-10-27 18:05:17 +00:00
commit 3102273ec7
538 changed files with 80203 additions and 0 deletions
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The one route every module's pages are mounted through: `/<org>/<workspace>/<project>/<module>/...`.
//
// A module that brings pages lists them in `modules/index.ts` (`modulePages`), keyed by the segment
// it answers; this route looks the segment up, calls the module's server component with the
// request's own caller and messages, and wraps what it returns in the transport its client
// components call procedures through. 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 { modulePages } from "@app/modules"
import { AppError } from "@conusai-kit/core"
import { notFound } from "next/navigation"
import type { ReactElement } from "react"
import { webRuntime } from "../../../../../../src/server/app"
import { pageContext } from "../../../../../../src/server/page"
import { AppTransport } from "../../../../../../src/ui/transport"
export const dynamic = "force-dynamic"
export default async function ModulePage({
params,
}: {
params: Promise<{ org: string; workspace: string; project: string; module: string; rest?: string[] }>
}): Promise<ReactElement> {
const { module, rest, ...slugs } = await params
if (!webRuntime().registry.modules.some((entry) => entry.id === module)) notFound()
// `Object.hasOwn`: a segment named `constructor` is not a module.
const load = Object.hasOwn(modulePages, module) ? modulePages[module] : undefined
if (!load) notFound()
const { caller, i18n, container } = await pageContext()
const page = await load()
const content = await page({ slugs, segments: rest ?? [], caller, i18n, container }).catch((error: unknown) => {
if (error instanceof AppError && error.status === 404) notFound()
throw error
})
return <AppTransport>{content}</AppTransport>
}
@@ -0,0 +1,84 @@
// 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.
import { PROFILE } from "@app/config"
import { documentGet } from "@app/modules/documents"
import { ExportButton, exportLabels, LineReview, reviewLabels } from "@app/modules/documents/ui"
import { AppError } from "@conusai-kit/core"
import { notFound } from "next/navigation"
import type { ReactElement } from "react"
import { pageContext } from "../../../../../../src/server/page"
import { AppTransport } from "../../../../../../src/ui/transport"
export const dynamic = "force-dynamic"
export default async function DocumentPage({
params,
}: {
params: Promise<{ org: string; workspace: string; project: string; fileId: string }>
}): Promise<ReactElement> {
const { fileId, ...slugs } = await params
if (PROFILE === "convex") notFound()
const { caller, i18n } = await pageContext()
const detail = await caller.call(documentGet, { ...slugs, fileId }).catch((error: unknown) => {
// A document of another organization and one that does not exist are the same page.
if (error instanceof AppError && error.status === 404) notFound()
throw error
})
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`documents.${key}`, values)
const failed = i18n.message("app.failed")
const { document, lines } = detail
return (
<>
<p>
<a href={`/${slugs.org}/${slugs.workspace}/${slugs.project}/documents`}>
{i18n.message("documents.review.back")}
</a>
</p>
<h1>{i18n.message("documents.review.title", { filename: document.filename })}</h1>
<p>
{i18n.message("documents.list.status")}: {i18n.message(`documents.status.${document.status}`)}
{document.failureCode ? ` ${i18n.message(`documents.failure.${document.failureCode}`)}` : ""}
</p>
<p>
{i18n.message("documents.review.pendingCount", { count: document.pending })}.{" "}
{i18n.message("documents.review.approvedCount", { count: document.approved })}.
</p>
{document.status === "queued" || document.status === "processing" ? (
<p role="status">{i18n.message("documents.review.waiting")}</p>
) : null}
{document.skippedPages.length > 0 ? (
<section aria-labelledby="skipped-pages">
<h2 id="skipped-pages">{i18n.message("documents.review.skippedTitle")}</h2>
<ul>
{document.skippedPages.map((skipped) => (
<li key={skipped.page}>
{i18n.message("documents.review.skippedPage", {
page: skipped.page,
kind: i18n.message(`documents.review.pageClass.${skipped.pageClass}`),
})}
</li>
))}
</ul>
</section>
) : null}
<AppTransport>
<LineReview
slugs={slugs}
fileId={fileId}
lines={lines}
labels={reviewLabels(t, {
failed,
aiDisclosure: i18n.message("ui.aiDisclosure"),
pageWord: i18n.message("ui.page"),
})}
locale={i18n.locale}
/>
<ExportButton slugs={slugs} fileId={fileId} approved={document.approved} labels={exportLabels(t, failed)} />
</AppTransport>
</>
)
}
@@ -0,0 +1,48 @@
// 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).
import { PROFILE } from "@app/config"
import { documentList, READABLE_CONTENT_TYPES } from "@app/modules/documents"
import { DocumentList, listLabels, UploadForm, uploadLabels } from "@app/modules/documents/ui"
import { downloadHref } from "@conusai-kit/ui/download"
import { notFound } from "next/navigation"
import type { ReactElement } from "react"
import { pageContext } from "../../../../../src/server/page"
import { AppTransport } from "../../../../../src/ui/transport"
export const dynamic = "force-dynamic"
export default async function DocumentsPage({
params,
}: {
params: Promise<{ org: string; workspace: string; project: string }>
}): Promise<ReactElement> {
const slugs = await params
if (PROFILE === "convex") notFound()
const { caller, i18n } = await pageContext()
const { documents } = await caller.call(documentList, slugs)
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`documents.${key}`, values)
return (
<>
<h1>{i18n.message("documents.title")}</h1>
<p>{i18n.message("documents.intro")}</p>
<AppTransport>
<UploadForm
slugs={slugs}
accept={READABLE_CONTENT_TYPES}
labels={uploadLabels(t, i18n.message("app.failed"))}
/>
</AppTransport>
<DocumentList
documents={documents}
hrefFor={(fileId) => `/${slugs.org}/${slugs.workspace}/${slugs.project}/documents/${fileId}`}
downloadHrefFor={(fileId) => downloadHref("files.file.download", { ...slugs, fileId })}
labels={listLabels(t)}
formatDate={(iso) => i18n.date(new Date(iso))}
/>
</>
)
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The project layout: 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 response streams (spec §3.3). `tenancy.project.get` resolves the whole path from the
// organization down, each step a scoped read, so nothing here re-implements the wall.
import { projectGet } from "@conusai-kit/tenancy"
import type { ReactNode } from "react"
import { orFallback } from "../../../../src/server/guard"
import { pageContext } from "../../../../src/server/page"
export const dynamic = "force-dynamic"
export default async function ProjectLayout({
children,
params,
}: {
children: ReactNode
params: Promise<{ org: string; workspace: string; project: string }>
}): Promise<ReactNode> {
const { org, workspace, project } = await params
const { caller, path } = await pageContext()
await orFallback(path, () => caller.call(projectGet, { org, workspace, project }))
return children
}
@@ -0,0 +1,53 @@
// 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/ui/projects.ts), so a module added tomorrow
// appears here with no edit to this file.
import { projectGet, workspaceList } from "@conusai-kit/tenancy"
import type { ReactElement } from "react"
import { orFallback } from "../../../../src/server/guard"
import { pageContext } from "../../../../src/server/page"
import { Breadcrumb } from "../../../../src/ui/breadcrumb"
import { PROJECT_ENTRIES, projectHref } from "../../../../src/ui/projects"
export const dynamic = "force-dynamic"
export default async function ProjectHome({
params,
}: {
params: Promise<{ org: string; workspace: string; project: string }>
}): Promise<ReactElement> {
const { org, workspace, project } = await params
const { caller, i18n, path } = await pageContext()
const { found, workspaceName } = await orFallback(path, async () => {
const [found, { workspaces }] = await Promise.all([
caller.call(projectGet, { org, workspace, project }),
caller.call(workspaceList, { org }),
])
return { found, workspaceName: workspaces.find((entry) => entry.slug === workspace)?.name ?? workspace }
})
return (
<>
<Breadcrumb
label={i18n.message("shell.breadcrumb")}
crumbs={[
{ text: i18n.message("app.workspaces"), href: `/${org}/workspaces` },
{ text: workspaceName, href: `/${org}/${workspace}` },
{ text: found.name, href: projectHref(org, workspace, project) },
]}
/>
<h1>{i18n.message("project.title", { name: found.name })}</h1>
<p>{i18n.message("project.module", { module: found.moduleKey })}</p>
{found.archived ? <p role="status">{i18n.message("project.archived")}</p> : null}
<h2>{i18n.message("project.work")}</h2>
<ul>
{PROJECT_ENTRIES.map((entry) => (
<li key={entry.href}>
<a href={projectHref(org, workspace, project, entry)}>{i18n.message(entry.label)}</a>
</li>
))}
</ul>
</>
)
}
+26
View File
@@ -0,0 +1,26 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The workspace layout: the workspace has to be one this person can see, and the page says 404 before
// it starts streaming 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.
import { workspaceList } from "@conusai-kit/tenancy"
import { notFound } from "next/navigation"
import type { ReactNode } from "react"
import { orFallback } from "../../../src/server/guard"
import { pageContext } from "../../../src/server/page"
export const dynamic = "force-dynamic"
export default async function WorkspaceLayout({
children,
params,
}: {
children: ReactNode
params: Promise<{ org: string; workspace: string }>
}): Promise<ReactNode> {
const { org, workspace } = await params
const { caller, path } = await pageContext()
const { workspaces } = await orFallback(path, () => caller.call(workspaceList, { org }))
if (!workspaces.some((entry) => entry.slug === workspace)) notFound()
return children
}
+92
View File
@@ -0,0 +1,92 @@
// 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 { projectList, workspaceList } from "@conusai-kit/tenancy"
import type { ReactElement } from "react"
import { orFallback } from "../../../src/server/guard"
import { holdsAtLeast, pageContext } from "../../../src/server/page"
import { Breadcrumb } from "../../../src/ui/breadcrumb"
import { ProcedureForm } from "../../../src/ui/procedure-form"
import { PROJECT_MODULE_KEYS, projectHref } from "../../../src/ui/projects"
export const dynamic = "force-dynamic"
export default async function WorkspaceHome({
params,
}: {
params: Promise<{ org: string; workspace: string }>
}): Promise<ReactElement> {
const { org, workspace } = await params
const page = await pageContext()
const { caller, i18n, path } = page
const { name, projects } = await orFallback(path, async () => {
const [{ projects }, { workspaces }] = await Promise.all([
caller.call(projectList, { org, workspace }),
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 = await holdsAtLeast(page, org, "editor")
return (
<>
<Breadcrumb
label={i18n.message("shell.breadcrumb")}
crumbs={[
{ text: i18n.message("app.workspaces"), href: `/${org}/workspaces` },
{ text: name, href: `/${org}/${workspace}` },
]}
/>
<h1>{i18n.message("workspace.title", { name })}</h1>
<h2>{i18n.message("workspace.projects")}</h2>
{projects.length === 0 ? (
<p>{i18n.message("workspace.noProjects")}</p>
) : (
<table>
<thead>
<tr>
<th scope="col">{i18n.message("app.name")}</th>
<th scope="col">{i18n.message("workspace.module")}</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.slug}>
<td>
<a href={projectHref(org, workspace, project.slug)}>{project.name}</a>
</td>
<td>{project.moduleKey}</td>
</tr>
))}
</tbody>
</table>
)}
{mayCreate ? (
<>
<h2>{i18n.message("workspace.createProject")}</h2>
<ProcedureForm
procedure="tenancy.project.create"
hidden={{ org, workspace }}
submitLabel={i18n.message("app.create")}
failedTemplate={i18n.message("app.failed")}
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 })),
},
]}
/>
</>
) : null}
</>
)
}
+22
View File
@@ -0,0 +1,22 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The audit log is for administrators (spec §9.4). The role is asked for in this layout and not in the
// page: the page has a loading state, a loading state starts the response, and the response's status
// is fixed once it starts -- a member below `admin` must get a real 403, and a stranger a real 404.
import type { ReactNode } from "react"
import { requireRole } from "../../../src/server/guard"
import { pageContext } from "../../../src/server/page"
export const dynamic = "force-dynamic"
export default async function AuditLayout({
children,
params,
}: {
children: ReactNode
params: Promise<{ org: string }>
}): Promise<ReactNode> {
const { org } = await params
await requireRole(await pageContext(), org, "admin")
return children
}
+4
View File
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The loading state of this page (src/ui/loading-page.tsx says why it is here and not above the layouts).
export { LoadingPage as default } from "../../../src/ui/loading-page"
+105
View File
@@ -0,0 +1,105 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The audit viewer (spec §9.4). 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 (src/server/audit.ts).
//
// Administrators only, which the layout beside this page asks of the tenancy resolver
// (src/server/guard.ts) before the response starts.
import { orgGet } from "@conusai-kit/tenancy"
import { ConusAIClient } from "@conusai-sdk/node-sdk"
import type { ReactElement } from "react"
import { webRuntime } from "../../../src/server/app"
import { isOwnedBy } from "../../../src/server/audit"
import { pageContext } from "../../../src/server/page"
export const dynamic = "force-dynamic"
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
/**
* 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 { env } = webRuntime()
const platform = new ConusAIClient({
baseUrl: env.deployment.CONUSAI_API_URL,
apiKey: env.deployment.CONUSAI_API_TOKEN,
})
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 default async function Audit({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
const { org } = await params
const { caller, i18n } = await pageContext()
// The layout already established membership; the id is what subscriber-written events carry.
const organization = await 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 (
<>
<h1>{i18n.message("app.audit")}</h1>
{rows.length === 0 ? (
<p>{i18n.message("app.auditEmpty")}</p>
) : (
<table>
<thead>
<tr>
<th scope="col">{i18n.message("app.when")}</th>
<th scope="col">{i18n.message("app.action")}</th>
<th scope="col">{i18n.message("app.outcome")}</th>
<th scope="col">{i18n.message("app.resource")}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td>{i18n.date(new Date(row.occurred_at))}</td>
<td>{row.action}</td>
<td>{row.outcome}</td>
<td>{row.resource_type ?? ""}</td>
</tr>
))}
</tbody>
</table>
)}
</>
)
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// 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.
import { logoGet } from "@app/modules/branding"
import { AppError } from "@conusai-kit/core"
import { pageContext } from "../../../../../src/server/page"
export const dynamic = "force-dynamic"
export async function GET(
request: Request,
{ params }: { params: Promise<{ org: string; variant: string }> },
): Promise<Response> {
const { org, variant } = await params
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()
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 })
}
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// 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.
import { appBrand } from "@app/config"
import { brandGet, renderShareImage, shareBrand } from "@app/modules/branding"
import { AppError } from "@conusai-kit/core"
import { orgGet } from "@conusai-kit/tenancy"
import { appIdentity, readersFor } from "../../../../src/server/brand-assets"
import { pageContext } from "../../../../src/server/page"
export const dynamic = "force-dynamic"
export async function GET(_request: Request, { params }: { params: Promise<{ org: string }> }): Promise<Response> {
const { org } = await params
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 })
}
}
+43
View File
@@ -0,0 +1,43 @@
// 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 { PROFILE } from "@app/config"
import { brandGet } from "@app/modules/branding"
import { BrandForm, brandFormLabels } from "@app/modules/branding/ui"
import { tokens } from "@conusai-kit/brand"
import { notFound } from "next/navigation"
import type { ReactElement } from "react"
import { orFallback } from "../../../src/server/guard"
import { holdsAtLeast, pageContext } from "../../../src/server/page"
import { AppTransport } from "../../../src/ui/transport"
export const dynamic = "force-dynamic"
export default async function BrandingPage({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
const { org } = await params
if (PROFILE === "convex") notFound()
const page = await pageContext()
const { caller, i18n, path } = page
const brand = await orFallback(path, () => caller.call(brandGet, { org }))
const canEdit = await holdsAtLeast(page, org, "admin")
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`branding.${key}`, values)
return (
<>
<h1>{i18n.message("branding.title")}</h1>
<p>{i18n.message("branding.intro")}</p>
<AppTransport>
<BrandForm
org={org}
brand={brand}
canEdit={canEdit}
defaultPrimary={tokens.color.dark.accent}
labels={brandFormLabels(t, i18n.message("app.failed"))}
/>
</AppTransport>
</>
)
}
+140
View File
@@ -0,0 +1,140 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization layout. The scope is resolved on the server, once, by calling an
// organization-scoped procedure: a member who is not a member gets the same NotFound a stranger
// does, and nothing below this layout has to check again (spec §3.3, §6.4).
//
// It is also where the organization's own brand is applied (spec §10.3): the colours as custom
// properties on an element wrapped around everything the organization sees, so the first paint of
// every page is already branded, and the display name and logo in the switcher -- beside the
// organization switcher itself, for a person who belongs to more than one.
import { PROFILE } from "@app/config"
import { appModules } from "@app/modules"
import { type BrandView, logoUrl, tenantLogo, tenantStyle } from "@app/modules/branding"
import { atLeast, brandingSchema, meGet, ORG_ROLE_AS_ROLE, orgGet } from "@conusai-kit/tenancy"
import { AppShell } from "@conusai-kit/ui"
import { redirect } from "next/navigation"
import type { CSSProperties, ReactElement, ReactNode } from "react"
import { webRuntime } from "../../src/server/app"
import { orFallback, signInHref } from "../../src/server/guard"
import { pageContext } from "../../src/server/page"
import { ClientModules } from "../../src/ui/client-modules"
import { OrganizationSwitch } from "../../src/ui/shell-controls"
export const dynamic = "force-dynamic"
// No generateMetadata here: the organization's share image (`/{org}/brand/og`) answers 404 to
// anyone who is not a signed-in member, which is every link-preview crawler, and org existence is
// deliberately indistinguishable from the outside. The root layout's metadata therefore stands:
// the app's own public share image.
/** One stored logo, at 1x and at 2x for a high-density screen. */
function TenantLogo({ className, path }: { className: string; path: string }): ReactElement {
return (
<img
className={className}
src={logoUrl(path, "1x")}
srcSet={`${logoUrl(path, "1x")} 1x, ${logoUrl(path, "2x")} 2x`}
alt=""
/>
)
}
/** The modules' navigation, with each link made absolute inside this organization. */
const navigationFor = (org: string) =>
appModules
.filter((module) => PROFILE !== "convex" || ["tenancy", "auth", "files", "notes"].includes(module.id))
.map((module) =>
module.ui?.nav
? {
...module,
ui: { ...module.ui, nav: module.ui.nav.map((item) => ({ ...item, href: `/${org}/${item.href}` })) },
}
: module,
)
/** 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,
}
}
export default async function OrgLayout({
children,
params,
}: {
children: ReactNode
params: Promise<{ org: string }>
}): Promise<ReactElement> {
const { org } = await params
const { caller, i18n, signedIn, path } = await pageContext()
if (!signedIn) redirect(signInHref(path))
// A stranger and a missing organization both end at the not-found page (guard.ts): the words and
// the status are the same, so the layout cannot be used to ask which organizations exist.
const [organization, me] = await orFallback(path, () =>
Promise.all([caller.call(orgGet, { org }), caller.call(meGet, undefined)]),
)
const role = me.organizations.find((entry) => entry.slug === org)?.role
// The permissions this membership holds, so the shell drops nav items that would only be
// refused (kit-ui filters when the prop is given; the registry is the permission source).
const heldPermissions =
role === undefined
? []
: Object.entries(webRuntime().registry.permissions)
.filter(([, declared]) => atLeast(ORG_ROLE_AS_ROLE[role], declared.role))
.map(([name]) => name)
// The audit log and the brand are for administrators; a link a member would only be refused at is noise.
const administers = role === "admin" || role === "owner"
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")
// The shell renders the <main> every page inside this layout fills, so an organization page
// returns its content and not a second landmark (kit-ui's AppShell is the accessible one).
return (
<div
data-tenant-brand={brand.primary ?? brand.accent ?? undefined}
style={tenantStyle(brand).style as CSSProperties}
>
<AppShell
modules={navigationFor(org)}
scope="org"
t={(key) => i18n.message(key)}
navigationLabel={i18n.message("ui.navigation")}
permissions={heldPermissions}
switcher={
<>
<a href={`/${org}`} className="app-brand-link">
{darkLogo ? <TenantLogo className="app-logo app-logo-dark" path={darkLogo.path} /> : null}
{lightLogo ? <TenantLogo className="app-logo app-logo-light" path={lightLogo.path} /> : null}
{brand.displayName ?? organization.name}
</a>
{me.organizations.length > 1 ? (
<OrganizationSwitch
current={org}
label={i18n.message("shell.switchOrganization")}
organizations={me.organizations.map((entry) => ({ slug: entry.slug, name: entry.name }))}
/>
) : null}
{administers ? <a href={`/${org}/audit`}>{i18n.message("app.audit")}</a> : null}
{administers && PROFILE !== "convex" ? (
<a href={`/${org}/branding`}>{i18n.message("branding.title")}</a>
) : null}
</>
}
>
{children}
</AppShell>
{/* The enabled modules' browser providers (an in-app guide's overlay), beside the page. */}
<ClientModules context={{ org, locale: i18n.locale, user: me.userId }} />
</div>
)
}
+4
View File
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The loading state of this page (src/ui/loading-page.tsx says why it is here and not above the layouts).
export { LoadingPage as default } from "../../../src/ui/loading-page"
+113
View File
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Members, their roles and the organization's open invitations (spec §3.2). Every change is a
// procedure call: the role scale, the "nobody grants a role above their own" rule and the last
// owner's protection are kit-tenancy's, not this page's. What this page decides is only who is shown
// the controls: administrators, who hold `tenancy.member.invite` and `tenancy.member.manage`.
import { invitationList, memberList, ORG_ROLES } from "@conusai-kit/tenancy"
import type { ReactElement } from "react"
import { orFallback } from "../../../src/server/guard"
import { holdsAtLeast, pageContext } from "../../../src/server/page"
import { InviteMember } from "../../../src/ui/invite-member"
import { ProcedureForm } from "../../../src/ui/procedure-form"
export const dynamic = "force-dynamic"
export default async function Members({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
const { org } = await params
const page = await pageContext()
const { caller, i18n, path } = page
const { members } = await orFallback(path, () => caller.call(memberList, { org }))
const administers = await holdsAtLeast(page, org, "admin")
const { invitations } = administers
? await orFallback(path, () => caller.call(invitationList, { org }))
: { invitations: [] }
const roleLabel = (role: string): string => i18n.message(`roles.${role}`)
const roleOptions = ORG_ROLES.map((role) => ({ value: role, label: roleLabel(role) }))
return (
<>
<h1>{i18n.message("app.members")}</h1>
<p>{i18n.message("app.memberCount", { count: members.length })}</p>
<table>
<thead>
<tr>
<th scope="col">{i18n.message("app.name")}</th>
<th scope="col">{i18n.message("app.email")}</th>
<th scope="col">{i18n.message("app.role")}</th>
</tr>
</thead>
<tbody>
{members.map((member) => (
<tr key={member.userId}>
<td>{member.displayName}</td>
<td>{member.email}</td>
<td>{roleLabel(member.role)}</td>
</tr>
))}
</tbody>
</table>
{administers ? (
<>
<h2>{i18n.message("app.invite")}</h2>
<InviteMember
org={org}
roles={roleOptions}
labels={{
email: i18n.message("app.email"),
role: i18n.message("app.role"),
submit: i18n.message("app.invite"),
created: i18n.message("members.created"),
link: i18n.message("members.link"),
failed: i18n.message("app.failed"),
}}
/>
<h2>{i18n.message("members.openInvitations")}</h2>
{invitations.length === 0 ? (
<p>{i18n.message("members.noInvitations")}</p>
) : (
<table>
<thead>
<tr>
<th scope="col">{i18n.message("app.email")}</th>
<th scope="col">{i18n.message("app.role")}</th>
<th scope="col">{i18n.message("members.invitedBy")}</th>
<th scope="col">{i18n.message("members.expires")}</th>
</tr>
</thead>
<tbody>
{invitations.map((invitation) => (
<tr key={invitation.invitationId}>
<td>{invitation.email}</td>
<td>{roleLabel(invitation.role)}</td>
<td>{invitation.inviter.displayName}</td>
<td>{i18n.date(new Date(invitation.expiresAt))}</td>
</tr>
))}
</tbody>
</table>
)}
<h2>{i18n.message("members.changeRole")}</h2>
<ProcedureForm
procedure="tenancy.member.change-role"
hidden={{ org }}
submitLabel={i18n.message("app.save")}
failedTemplate={i18n.message("app.failed")}
fields={[
{
name: "userId",
label: i18n.message("app.members"),
required: true,
options: members.map((member) => ({ value: member.userId, label: member.email })),
},
{ name: "role", label: i18n.message("app.role"), required: true, options: roleOptions },
]}
/>
</>
) : null}
</>
)
}
+55
View File
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's workspaces and the projects of each (spec §3.1).
import { projectList, workspaceList } from "@conusai-kit/tenancy"
import type { ReactElement } from "react"
import { orFallback } from "../../src/server/guard"
import { pageContext } from "../../src/server/page"
import { PROJECT_ENTRIES, projectHref } from "../../src/ui/projects"
export const dynamic = "force-dynamic"
export default async function OrgHome({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
const { org } = await params
const { caller, i18n, path } = await pageContext()
const projects = await orFallback(path, async () => {
const { workspaces } = await caller.call(workspaceList, { org })
return Promise.all(
workspaces.map(async (workspace) => ({
workspace,
projects: (await caller.call(projectList, { org, workspace: workspace.slug })).projects,
})),
)
})
return (
<>
<h1>{i18n.message("app.workspaces")}</h1>
<p>
<a href={`/${org}/workspaces`}>{i18n.message("workspace.create")}</a>
</p>
{projects.length === 0 ? <p>{i18n.message("workspace.none")}</p> : null}
{projects.map(({ workspace, projects: inWorkspace }) => (
<section key={workspace.slug}>
<h2>
<a href={`/${org}/${workspace.slug}`}>{workspace.name}</a>
</h2>
<p>{i18n.message("workspace.projectCount", { count: inWorkspace.length })}</p>
<ul>
{inWorkspace.map((project) => (
<li key={project.slug}>
<a href={projectHref(org, workspace.slug, project.slug)}>{project.name}</a>:
{PROJECT_ENTRIES.map((entry) => (
<span key={entry.href}>
{" "}
<a href={projectHref(org, workspace.slug, project.slug, entry)}>{i18n.message(entry.label)}</a>
</span>
))}
</li>
))}
</ul>
</section>
))}
</>
)
}
+70
View File
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The organization's own settings. `tenancy.org.update` needs the admin role, so the page asks for
// it (src/server/guard.ts): a member below it gets the forbidden page, a stranger the not-found
// page. Workspaces are created on their own page (`/{org}/workspaces`).
import { settingsGet } from "@app/modules/knowledge"
import { SettingsSection } from "@app/modules/knowledge/ui"
import { orgGet } from "@conusai-kit/tenancy"
import type { ReactElement } from "react"
import { orFallback, requireRole } from "../../../src/server/guard"
import { pageContext } from "../../../src/server/page"
export const dynamic = "force-dynamic"
export default async function Settings({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
const { org } = await params
const page = await pageContext()
const { caller, i18n, path } = page
await requireRole(page, org, "admin")
const organization = await orFallback(path, () => caller.call(orgGet, { org }))
const knowledge = await orFallback(path, () => caller.call(settingsGet, { org }))
// 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}`)
return (
<>
<h1>{i18n.message("app.settings")}</h1>
<dl>
<dt>{i18n.message("app.name")}</dt>
<dd>{organization.name}</dd>
<dt>{i18n.message("app.slug")}</dt>
<dd>{organization.slug}</dd>
<dt>{i18n.message("app.status")}</dt>
<dd>{organization.status}</dd>
</dl>
<p>
<a href={`/${org}/workspaces`}>{i18n.message("app.workspaces")}</a>
</p>
<SettingsSection
org={org}
initial={knowledge}
labels={{
heading: t("settings.heading"),
state: t("settings.state"),
stateOn: t("settings.stateOn"),
stateOff: t("settings.stateOff"),
backend: t("settings.backend"),
backendVectors: t("settings.backendVectors"),
source: t("settings.source"),
sourceOperator: t("settings.sourceOperator"),
sourceManaged: t("settings.sourceManaged"),
endpoint: t("settings.endpoint"),
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"),
}}
/>
</>
)
}
+79
View File
@@ -0,0 +1,79 @@
// 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 { workspaceList } from "@conusai-kit/tenancy"
import type { ReactElement } from "react"
import { orFallback } from "../../../src/server/guard"
import { holdsAtLeast, pageContext } from "../../../src/server/page"
import { ProcedureForm } from "../../../src/ui/procedure-form"
export const dynamic = "force-dynamic"
export default async function Workspaces({ params }: { params: Promise<{ org: string }> }): Promise<ReactElement> {
const { org } = await params
const page = await pageContext()
const { caller, i18n, path } = page
const { workspaces } = await orFallback(path, () => caller.call(workspaceList, { org }))
const mayCreate = await holdsAtLeast(page, org, "admin")
return (
<>
<h1>{i18n.message("workspace.list")}</h1>
{workspaces.length === 0 ? (
<p>{i18n.message("workspace.none")}</p>
) : (
<table>
<thead>
<tr>
<th scope="col">{i18n.message("app.name")}</th>
<th scope="col">{i18n.message("workspace.visibility")}</th>
</tr>
</thead>
<tbody>
{workspaces.map((workspace) => (
<tr key={workspace.slug}>
<td>
<a href={`/${org}/${workspace.slug}`}>{workspace.name}</a>
{workspace.isDefault ? ` (${i18n.message("workspace.default")})` : ""}
</td>
<td>
{workspace.visibility === "org"
? i18n.message("workspace.visibilityOrg")
: i18n.message("workspace.visibilityRestricted")}
</td>
</tr>
))}
</tbody>
</table>
)}
{mayCreate ? (
<>
<h2>{i18n.message("workspace.create")}</h2>
<ProcedureForm
procedure="tenancy.workspace.create"
hidden={{ org }}
submitLabel={i18n.message("app.create")}
failedTemplate={i18n.message("app.failed")}
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: i18n.message("workspace.visibilityOrg") },
{ value: "restricted", label: i18n.message("workspace.visibilityRestricted") },
],
},
]}
/>
</>
) : null}
</>
)
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Every HTTP entry point of this app but the sign-in redirects: /api/health, /api/ready,
// /api/openapi.json and /api/v1/<procedure>. One file, because there is one mount (spec §6.2).
import { handle } from "../../../src/server/router"
// A procedure call reads a session and a database; nothing here may be cached or prerendered.
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export const GET = handle
export const POST = handle
// A procedure whose payload is a byte stream (kit-core .stream(field)) is answered on PUT.
export const PUT = handle
+19
View File
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// /api/auth/*: the same-origin route from this app's pages to Better Auth on its Convex deployment
// (AUTH_PROVIDER=convex-better-auth). Only the endpoints a sign-in, sign-up, sign-out, password
// change, device revocation or account deletion needs are forwarded, and no bearer comes back
// (@conusai-kit/auth-convex-better-auth's proxy). A deployment that selected another provider has no
// such route.
import { loadAuthConfig } from "@conusai-kit/auth"
import { betterAuthRuntime } from "../../../../src/server/app"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
async function handle(request: Request): Promise<Response> {
if (loadAuthConfig().provider !== "convex-better-auth") return new Response(null, { status: 404 })
return betterAuthRuntime().proxy(request)
}
export const GET = handle
export const POST = handle
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// 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.
import { appBrand } from "@app/config"
import { renderShareImage, shareBrand } from "@app/modules/branding"
import { appIdentity, appOnlyReaders } from "../../../../src/server/brand-assets"
export const dynamic = "force-dynamic"
let drawn: Promise<Buffer<ArrayBuffer>> | undefined
export async function GET(): 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 })
}
}
+14
View File
@@ -0,0 +1,14 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// GET /auth/callback: the provider sends the browser back here (MIB I3's path). The code is
// exchanged on the server, the tokens are sealed into the session row, and the browser receives
// one sealed cookie whose plaintext is a random handle. A failure of any step ends at the sign-in
// page with a code for the person to read, never at a 500 (src/server/auth-response.ts).
import { authRoutes } from "../../../src/server/app"
import { authResponse } from "../../../src/server/auth-response"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export async function GET(request: Request): Promise<Response> {
return authResponse(request, async () => (await authRoutes()).callback(request))
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// GET /auth/dev-login?email=…&return_to=…: a sign-in for a developer with no identity provider.
// @conusai-kit/auth refuses to build it in a production deployment, and refuses again at boot if
// AUTH_DEV_LOGIN is set there, so this route cannot become a way in (spec §7).
import { createDevLogin, loadAuthConfig } from "@conusai-kit/auth"
import { authRoutes, webRuntime } from "../../../src/server/app"
import { safeReturnTo, signInErrorHref } from "../../../src/server/auth-errors"
import { authResponse, redirectTo } from "../../../src/server/auth-response"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export async function GET(request: Request): Promise<Response> {
const config = loadAuthConfig()
if (!config.devLogin) return new Response("dev login is not enabled for this deployment", { status: 404 })
return authResponse(request, async () => {
const runtime = webRuntime()
const data = runtime.authData ?? runtime.data
if (!data) throw new Error("The selected profile has no auth data source")
const { sessions } = await authRoutes()
const response = await createDevLogin({ config, data, sessions }).signIn(request)
// An address the adapter would not take is a failed sign-in like any other, not a bare 400.
if (response.status === 400) return redirectTo(signInErrorHref("failed"))
// The adapter always lands on `/`; the person was going somewhere (the invitation they were sent).
const target = safeReturnTo(new URL(request.url).searchParams.get("return_to"))
if (response.status !== 302 || target === "/") return response
const headers = new Headers(response.headers)
headers.set("location", target)
return new Response(null, { status: 302, headers })
})
}
+79
View File
@@ -0,0 +1,79 @@
// 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 (src/server/auth-response.ts). 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 { loadAuthConfig } from "@conusai-kit/auth"
import { redirect } from "next/navigation"
import type { ReactElement } from "react"
import { AUTH_ERROR_MESSAGES, parseAuthError, safeReturnTo } from "../../../src/server/auth-errors"
import { betterAuthFormLabels, MIN_PASSWORD_LENGTH } from "../../../src/server/better-auth" // kit:convex-better-auth
import { pageContext } from "../../../src/server/page"
import { BetterAuthForm } from "../../../src/ui/better-auth-form" // kit:convex-better-auth
import { StateView } from "../../../src/ui/state-view"
export const dynamic = "force-dynamic"
export default async function SignIn({
searchParams,
}: {
searchParams: Promise<{ error?: string; return_to?: string }>
}): Promise<ReactElement> {
const { error, return_to: returnToParam } = await searchParams
const { i18n, signedIn } = 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) redirect(returnTo)
const config = loadAuthConfig()
const next = returnTo === "/" ? "" : `?return_to=${encodeURIComponent(returnTo)}`
const betterAuth = config.provider === "convex-better-auth" // kit:convex-better-auth
return (
<StateView title={i18n.message("signin.title")}>
{code ? (
<p className="app-error" role="alert">
{i18n.message(AUTH_ERROR_MESSAGES[code])}
</p>
) : (
<p>{i18n.message("signin.intro")}</p>
)}
{config.oidc ? (
<p>
<a href={`/auth/sign-in/start${next}`}>{i18n.message("signin.continue")}</a>
</p>
) : null}
{!config.oidc && config.devLogin ? (
<form method="get" action="/auth/dev-login">
<h2>{i18n.message("signin.devTitle")}</h2>
<p>{i18n.message("signin.devHelp")}</p>
{returnTo === "/" ? null : <input type="hidden" name="return_to" value={returnTo} />}
<label className="kit-field" htmlFor="dev-email">
{i18n.message("app.email")}
<input id="dev-email" name="email" type="email" required autoComplete="email" />
</label>
<button type="submit">{i18n.message("signin.devSubmit")}</button>
</form>
) : null}
{/* kit:convex-better-auth:begin */}
{betterAuth ? (
<>
<BetterAuthForm
mode="sign-in"
returnTo={returnTo}
minPasswordLength={MIN_PASSWORD_LENGTH}
labels={betterAuthFormLabels(i18n, "sign-in")}
/>
<p>
<a href={`/auth/sign-up${next}`}>{i18n.message("signin.account.toSignUp")}</a>
</p>
</>
) : null}
{/* kit:convex-better-auth:end */}
{!config.oidc && !config.devLogin && config.provider === "oidc" ? (
<p role="status">{i18n.message("signin.notConfigured")}</p>
) : null}
</StateView>
)
}
+16
View File
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// GET /auth/sign-in/start: the redirect to the identity provider (spec §7). The transaction cookie it
// sets carries the state, the nonce and the PKCE verifier, sealed; the browser gets no token. The
// page people see before it is /auth/sign-in. A deployment with no provider has nothing to redirect
// to, and sends the visitor back to that page, which says so.
import { loadAuthConfig } from "@conusai-kit/auth"
import { authRoutes } from "../../../../src/server/app"
import { authResponse, redirectTo } from "../../../../src/server/auth-response"
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export async function GET(request: Request): Promise<Response> {
if (!loadAuthConfig().oidc) return redirectTo("/auth/sign-in")
return authResponse(request, async () => (await authRoutes()).signIn(request))
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// POST /auth/sign-out: revokes the session row and redirects to the provider's end-session
// endpoint, which sends the browser to /auth/signed-out.
//
// POST, with the fetch-metadata half of csrf.ts's check: revoking a session is a state change, so
// it must not hang on a GET, which a prefetch, a crawler or an <img> fires unprompted, and a
// cross-site form POST is refused the same way the API refuses one. The synchronizer token itself
// cannot be checked here -- a plain HTML form cannot send a header -- so the metadata a browser
// always sends is the check.
import { authRoutes } from "../../../src/server/app"
import { authResponse } from "../../../src/server/auth-response"
import { betterAuthSignOut } from "../../../src/server/better-auth-session" // kit:convex-better-auth
export const dynamic = "force-dynamic"
export const runtime = "nodejs"
export async function GET(): Promise<Response> {
return new Response("signing out is a POST", { status: 405, headers: { allow: "POST" } })
}
export async function POST(request: Request): Promise<Response> {
const site = request.headers.get("sec-fetch-site")
if (site !== null && site !== "same-origin" && site !== "none") {
return new Response("a cross-site sign-out is refused", { status: 403 })
}
const betterAuth = await betterAuthSignOut(request) // kit:convex-better-auth
if (betterAuth) return betterAuth // kit:convex-better-auth
return authResponse(request, async () => (await authRoutes()).signOut(request))
}
+42
View File
@@ -0,0 +1,42 @@
// 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 { loadAuthConfig } from "@conusai-kit/auth"
import { notFound, redirect } from "next/navigation"
import type { ReactElement } from "react"
import { safeReturnTo } from "../../../src/server/auth-errors"
import { betterAuthFormLabels, MIN_PASSWORD_LENGTH } from "../../../src/server/better-auth"
import { pageContext } from "../../../src/server/page"
import { BetterAuthForm } from "../../../src/ui/better-auth-form"
import { StateView } from "../../../src/ui/state-view"
export const dynamic = "force-dynamic"
export default async function SignUp({
searchParams,
}: {
searchParams: Promise<{ return_to?: string }>
}): Promise<ReactElement> {
if (loadAuthConfig().provider !== "convex-better-auth") notFound()
const { return_to: returnToParam } = await searchParams
const { i18n, signedIn } = await pageContext()
const returnTo = safeReturnTo(returnToParam)
if (signedIn) redirect(returnTo)
const next = returnTo === "/" ? "" : `?return_to=${encodeURIComponent(returnTo)}`
return (
<StateView title={i18n.message("signin.account.signUpTitle")}>
<p>{i18n.message("signin.account.signUpIntro")}</p>
<BetterAuthForm
mode="sign-up"
returnTo={returnTo}
minPasswordLength={MIN_PASSWORD_LENGTH}
labels={betterAuthFormLabels(i18n, "sign-up")}
/>
<p>
<a href={`/auth/sign-in${next}`}>{i18n.message("signin.account.toSignIn")}</a>
</p>
</StateView>
)
}
+18
View File
@@ -0,0 +1,18 @@
// 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 type { ReactElement } from "react"
import { pageContext } from "../../../src/server/page"
export const dynamic = "force-dynamic"
export default async function SignedOut(): Promise<ReactElement> {
const { i18n } = await pageContext()
return (
<main>
<h1>{i18n.message("app.signedOut")}</h1>
<p>{i18n.message("app.signedOutBody")}</p>
<a href="/auth/sign-in">{i18n.message("app.signIn")}</a>
</main>
)
}
+12
View File
@@ -0,0 +1,12 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// What a person sees when a page throws (spec §10.4). The strings come from the root layout's
// `BoundaryMessages`, in the person's language; the error is reported to observability from here
// because React swallows what a boundary catches (src/ui/boundary.tsx).
import { ErrorView } from "../src/ui/boundary"
export default function ErrorPage({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
return <ErrorView error={error} retry={retry} />
}
+20
View File
@@ -0,0 +1,20 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The page for a member whose role does not allow what they asked for (spec §3.2). It is only ever
// shown to somebody who is in the organization -- a stranger gets the not-found page -- so it says
// nothing that the person did not already know.
import type { ReactElement } from "react"
import { pageContext } from "../src/server/page"
import { StateView } from "../src/ui/state-view"
export default async function Forbidden(): Promise<ReactElement> {
const { i18n } = await pageContext()
return (
<StateView
title={i18n.message("boundary.forbiddenTitle")}
actions={<a href="/">{i18n.message("boundary.home")}</a>}
>
<p>{i18n.message("boundary.forbiddenBody")}</p>
</StateView>
)
}
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The last boundary: the root layout itself threw, so there is no document, no stylesheet and no
// provider of strings -- this file renders its own <html> and carries its own two languages, chosen
// from the browser's. The server has already logged the error; this reports the browser's side.
import { useEffect } from "react"
import { reportClientError } from "../src/observe/browser"
const STRINGS = {
en: { title: "Something went wrong", body: "The app could not be shown. Try again.", retry: "Try again" },
lt: {
title: "Kažkas nepavyko",
body: "Programos parodyti nepavyko. Bandykite dar kartą.",
retry: "Bandyti dar kartą",
},
} as const
export default function GlobalError({ error, retry }: { error: Error & { digest?: string }; retry: () => void }) {
useEffect(() => {
reportClientError(error)
}, [error])
const locale = typeof navigator !== "undefined" && navigator.language.toLowerCase().startsWith("lt") ? "lt" : "en"
const strings = STRINGS[locale]
return (
<html lang={locale}>
<body>
<main>
<h1>{strings.title}</h1>
<p role="alert">{strings.body}</p>
<button type="button" onClick={retry}>
{strings.retry}
</button>
</main>
</body>
</html>
)
}
+281
View File
@@ -0,0 +1,281 @@
/* SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 */
/* The app's stylesheet. Every colour, font and radius is a brand token: this file declares none
of its own, so a white-label change is a change in config/brand.ts and nothing else
(decision AT-D11). The fonts are the self-hosted woff2 files of @conusai-kit/brand; no external
stylesheet or font host is referenced anywhere (self-hosted D20). */
@import "@conusai-kit/brand/tokens.css";
@import "@conusai-kit/ui/styles.css";
:root {
/* The brand's tokens are dark by default and `.light` carries the light variant, so the browser's
own colour scheme follows the class: a tenant brand's `light-dark()` ink (modules/branding)
resolves against the same choice the tokens make, and never against the operating system's. */
color-scheme: dark;
}
.light {
color-scheme: light;
}
body {
margin: 0;
background: var(--brand-background);
color: var(--brand-foreground);
font-family: var(--brand-font-sans);
font-size: var(--brand-font-size-root, 16px);
line-height: 1.5;
}
a {
/* The ink, not the fill: the brand's own teal is 1.8:1 as text on white, which is why the ink exists. */
color: var(--brand-accent-ink);
}
main {
padding: 1.5rem;
max-width: 72rem;
margin: 0 auto;
}
h1,
h2,
h3 {
font-family: var(--brand-font-display);
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(--brand-border-soft);
}
.app-shell-header {
display: flex;
gap: 1rem;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--brand-border-soft);
}
.app-error {
border: 1px solid var(--brand-border-soft);
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;
}
.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;
}
/* The brand settings' preview: the page's own tokens on a panel per theme, with the brand layer the
organization layout will write drawn on the panel itself. */
.brand-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
gap: 1rem;
}
.brand-preview-panel {
background: var(--brand-background);
color: var(--brand-foreground);
border: 1px solid var(--brand-border-strong);
border-radius: var(--brand-radius-panel);
padding: 1rem;
}
.brand-preview-action {
background: var(--brand-accent);
color: var(--brand-accent-foreground);
border: 0;
border-radius: var(--brand-radius-button);
padding: 0.5rem 1rem;
font: inherit;
}
.brand-preview-link {
color: var(--brand-accent-ink);
}
/* 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;
}
button,
select,
input {
font: inherit;
color: var(--brand-foreground);
background: var(--brand-panel);
border: 1px solid var(--brand-border-strong);
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(--brand-accent);
}
:focus-visible {
outline: 2px solid var(--brand-accent-ink);
outline-offset: 2px;
}
.kit-field {
margin-block-end: 0.75rem;
}
.app-state {
max-width: 40rem;
margin: 3rem auto;
}
.app-breadcrumb ol {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
margin: 0 0 1rem;
padding: 0;
}
.app-breadcrumb li + li::before {
content: "/";
margin-inline-end: 0.5rem;
color: var(--brand-text-70);
}
.app-link-field {
width: 100%;
box-sizing: border-box;
font-family: var(--brand-font-mono);
}
.app-loading {
display: flex;
gap: 0.75rem;
align-items: center;
color: var(--brand-text-80);
}
.app-spinner {
width: 1rem;
height: 1rem;
border: 2px solid var(--brand-border-strong);
border-top-color: var(--brand-accent);
border-radius: 50%;
animation: app-spin 0.8s linear infinite;
}
@keyframes app-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.app-spinner {
animation: none;
}
}
/* The organization shell of @conusai-kit/ui: the bar with the organization's name, its switcher and
its links, and the navigation of the modules, as two rows rather than a pile of unstyled elements. */
.kit-shell-bar {
display: flex;
flex-wrap: wrap;
gap: 1rem 1.5rem;
align-items: center;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--brand-border-soft);
}
.kit-shell nav {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--brand-border-soft);
}
.kit-shell nav ul {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
}
/* The account page's analytics panel (ADR-049): its two actions side by side, the irreversible one
marked with the brand's warning tokens, and the erasure's confirmation set apart. */
.app-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
/* The text stays the foreground colour, which keeps its contrast on the button's panel fill in both
themes (the warning ink on that fill falls under WCAG's 4.5:1); the danger is carried by the
heavier warning border and weight, not by colour alone, and the confirmation states it. */
.app-danger {
border: 2px solid var(--brand-warning);
color: var(--brand-foreground);
font-weight: 600;
}
.app-confirm {
border: 1px solid var(--brand-warning);
border-radius: var(--brand-radius-panel, 8px);
padding: 0.75rem 1rem;
margin-block: 0.75rem;
}
.app-stack {
display: grid;
gap: 0.5rem;
margin-block: 0.75rem;
}
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The page an invitation link leads to: /invitations/<organization>/<token> (spec §3.1).
//
// Opening it changes nothing. A visitor who is not signed in is asked to sign in -- with the address
// the invitation was sent to -- and comes back here; a signed-in person sees who they are signed in
// as and a button, and the button is what accepts. The page does not look the token up: only the
// invitee's own account may learn anything about it, and the answer to accepting is worded per state
// (used, expired, already a member) for exactly that account and as a plain "not valid" for everyone
// else (kit-tenancy), so this page cannot be used to find out which tokens or organizations exist.
import type { Metadata } from "next"
import type { ReactElement } from "react"
import { signInHref } from "../../../../src/server/guard"
import { pageContext } from "../../../../src/server/page"
import { InviteAccept } from "../../../../src/ui/invite-accept"
import { SignOut } from "../../../../src/ui/sign-out"
import { StateView } from "../../../../src/ui/state-view"
export const dynamic = "force-dynamic"
// The token is the credential: it must not leave in a Referer header, and the page is not for search.
export const metadata: Metadata = { referrer: "no-referrer", robots: { index: false, follow: false } }
export default async function Invitation({
params,
}: {
params: Promise<{ org: string; token: string }>
}): Promise<ReactElement> {
const { org, token } = await params
const { i18n, signedIn, user, path } = await pageContext()
const message = (key: string, values?: Record<string, string | number>): string => i18n.message(key, values)
return (
<StateView title={message("invite.title")}>
<p>{message("invite.intro", { org })}</p>
{signedIn ? (
<>
{user ? <p>{message("invite.signedInAs", { email: user.email })}</p> : null}
<InviteAccept
org={org}
token={token}
labels={{
accept: message("invite.accept"),
accepted: message("invite.accepted", { org }),
open: message("invite.open", { org }),
invalid: message("invite.error.invalid"),
used: message("invite.error.used"),
expired: message("invite.error.expired"),
member: message("invite.error.member"),
failed: message("invite.error.failed"),
}}
/>
<SignOut label={message("invite.switchAccount")} />
</>
) : (
<>
<p>{message("invite.signInFirst")}</p>
<p>
<a href={signInHref(path)}>{message("app.signIn")}</a>
</p>
</>
)}
</StateView>
)
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The root layout: the document, the brand layer and the app shell (spec §10.2).
//
// The brand's `--brand-*` declarations are written as a style attribute on <html> (src/ui/document.tsx),
// which is the layer a tenant brand overrides at runtime without any stylesheet being rebuilt.
import { appBrand, publicUrlOf } from "@app/config"
import { ACCOUNT_PATH } from "@app/modules/account/contract"
import type { Metadata, Viewport } from "next"
import type { ReactNode } from "react"
import { pageContext } from "../src/server/page"
import { BoundaryMessages } from "../src/ui/boundary"
import { brandMetadata, Document } from "../src/ui/document"
import { LanguageSwitch, ThemeToggle } from "../src/ui/shell-controls"
import { SignOut } from "../src/ui/sign-out"
import "./globals.css"
/**
* The app's own name and share image. `metadataBase` makes the image's address absolute, as a link preview needs, from
* the public URL the deployment states; it is read when the page is asked for, never at build (one image, every environment).
*/
export function generateMetadata(): Metadata {
return {
...brandMetadata(appBrand),
metadataBase: new URL(publicUrlOf()),
}
}
export const viewport: Viewport = { width: "device-width", initialScale: 1 }
export default async function RootLayout({ children }: { children: ReactNode }): Promise<ReactNode> {
const { i18n, signedIn, csrf, theme, observe } = await pageContext()
const message = (key: string): string => i18n.message(key)
return (
<Document
brand={appBrand}
locale={i18n.locale}
theme={theme}
signedIn={signedIn}
{...(csrf ? { csrf } : {})}
{...(observe ? { observe } : {})}
signIn={message("app.signIn")}
signOut={
<>
<a href={ACCOUNT_PATH}>{message("account.link")}</a>
<SignOut label={message("app.signOut")} />
</>
}
controls={
<>
<LanguageSwitch
current={i18n.locale}
signedIn={signedIn}
label={message("shell.language")}
options={[
{ value: "en", label: message("shell.languageEn") },
{ value: "lt", label: message("shell.languageLt") },
]}
failedTemplate={message("app.failed")}
/>
<ThemeToggle
initial={theme}
label={message("shell.theme")}
darkLabel={message("shell.themeDark")}
lightLabel={message("shell.themeLight")}
/>
</>
}
>
<BoundaryMessages
strings={{
title: message("boundary.errorTitle"),
body: message("boundary.errorBody"),
code: message("boundary.errorCode"),
retry: message("boundary.retry"),
home: message("boundary.home"),
}}
>
{children}
</BoundaryMessages>
</Document>
)
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The page for an address that leads nowhere (spec §3.3). It is the one answer for an organization
// that does not exist, an organization the person is not in, a workspace or a project of another
// organization, and a path no module answers: the words and the 404 status are the same for all of
// them, so the page cannot be used to find out which organizations exist.
import type { ReactElement } from "react"
import { pageContext } from "../src/server/page"
import { StateView } from "../src/ui/state-view"
export default async function NotFound(): Promise<ReactElement> {
const { i18n } = await pageContext()
return (
<StateView title={i18n.message("boundary.notFoundTitle")} actions={<a href="/">{i18n.message("boundary.home")}</a>}>
<p>{i18n.message("boundary.notFoundBody")}</p>
</StateView>
)
}
+32
View File
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Onboarding: the first organization, which creates its default workspace and project in one
// transaction (kit-tenancy's use case), with the signed-in user as its owner. Joining an existing
// organization is not done by name (spec §3.3): it is an invitation, which has its own page.
import { redirect } from "next/navigation"
import type { ReactElement } from "react"
import { signInHref } from "../../src/server/guard"
import { pageContext } from "../../src/server/page"
import { ProcedureForm } from "../../src/ui/procedure-form"
export const dynamic = "force-dynamic"
export default async function Onboarding(): Promise<ReactElement> {
const { i18n, signedIn, path } = await pageContext()
if (!signedIn) redirect(signInHref(path))
return (
<main>
<h1>{i18n.message("app.createOrganization")}</h1>
<ProcedureForm
procedure="tenancy.org.create"
submitLabel={i18n.message("app.create")}
failedTemplate={i18n.message("app.failed")}
after="/{slug}"
fields={[
{ name: "name", label: i18n.message("app.name"), required: true },
{ name: "slug", label: i18n.message("app.slug"), required: true },
]}
/>
</main>
)
}
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The entry page: the organizations this user belongs to, or the way in (spec §7).
import { meGet } from "@conusai-kit/tenancy"
import type { ReactElement } from "react"
import { orFallback } from "../src/server/guard"
import { pageContext } from "../src/server/page"
export const dynamic = "force-dynamic"
export default async function Home(): Promise<ReactElement> {
const { caller, signedIn, i18n, path } = await pageContext()
if (!signedIn) {
return (
<main>
<h1>{i18n.message("app.title")}</h1>
<p>{i18n.message("app.needsSignIn")}</p>
<a href="/auth/sign-in">{i18n.message("app.signIn")}</a>
</main>
)
}
// 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))
return (
<main>
<h1>{i18n.message("app.organizations")}</h1>
{me.organizations.length === 0 ? (
<p>{i18n.message("app.noOrganizations")}</p>
) : (
<ul>
{me.organizations.map((organization) => (
<li key={organization.slug}>
<a href={`/${organization.slug}`}>{organization.name}</a>{" "}
<span>{i18n.message(`roles.${organization.role}`)}</span>
</li>
))}
</ul>
)}
<a href="/onboarding">{i18n.message("app.createOrganization")}</a>
</main>
)
}
+72
View File
@@ -0,0 +1,72 @@
// 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. With the fake
// platform its procedures answer "unavailable".
import { ACCOUNT_PATH, accountDeletionStatus } from "@app/modules/account/contract"
import { analyticsLabels, deletionLabels, exportLabels, pageLabels } from "@app/modules/account/ui"
import { loadAuthConfig } from "@conusai-kit/auth"
import { meGet } from "@conusai-kit/tenancy"
import { redirect } from "next/navigation"
import type { ReactElement } from "react"
import { orFallback, signInHref } from "../../../src/server/guard"
import { pageContext } from "../../../src/server/page"
import { AccountPanels } from "../../../src/ui/account"
export const dynamic = "force-dynamic"
/**
* 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 default async function AccountPage(): Promise<ReactElement> {
const { caller, i18n, signedIn, path } = await pageContext()
if (!signedIn) redirect(signInHref(path))
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`account.${key}`, values)
const page = pageLabels(t)
const [me, status] = await orFallback(path, () =>
Promise.all([caller.call(meGet, undefined), caller.call(accountDeletionStatus, undefined)]),
)
const failed = i18n.message("app.failed")
return (
<main>
<h1>{page.title}</h1>
<p>{page.intro}</p>
<section aria-labelledby="account-profile">
<h2 id="account-profile">{page.profileHeading}</h2>
<dl>
<dt>{page.name}</dt>
<dd>{me.displayName}</dd>
<dt>{page.email}</dt>
<dd>{me.email}</dd>
</dl>
</section>
<AccountPanels
exportLabels={exportLabels(t, failed)}
analyticsLabels={analyticsLabels(t, failed)}
deletion={{
labels: deletionLabels(t, failed, status, me.email),
interactive: status.interactive,
recentSignIn: status.recentSignIn,
reauthHref: reauthHref(me.email),
deletion: status.deletion,
}}
/>
</main>
)
}
+8
View File
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Runs in the browser before the app hydrates (Next.js 15.3+). All of it is in src/observe/browser.ts,
// where a test can reach it; this file only connects the document to the SDK.
import { instrumentationClient } from "@conusai-sdk/observe-next/instrumentation-client"
import { startBrowserObserve } from "./src/observe/browser"
export const observe = startBrowserObserve(document, instrumentationClient)
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Next.js runs `register` once when a server instance starts, before it takes a request, and
// `onRequestError` for every error a render, a route or an action leaves unhandled (spec §9.5).
//
// `register` parses the deployment contract and nothing else: a missing or forbidden platform
// variable stops the server here, with a typed error that names it, before the first request. It
// builds nothing. Next.js bundles this file apart from the routes, and a registry or an error client
// built in this bundle would be a second copy of every contract the routes then fail to match
// ("procedure ... is not registered with this contract"); the runtime, observability included, is
// built on the first request by the routes' own copy (src/server/app.ts), and shared with this file only as
// the observability handle, which holds no kit module (config/src/observability.ts).
import type { Instrumentation } from "next"
export async function register(): Promise<void> {
if (process.env.NEXT_RUNTIME !== "nodejs") return
const { loadAppEnv } = await import("@app/config")
loadAppEnv("web")
}
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
if (process.env.NEXT_RUNTIME !== "nodejs") return
const [{ currentObservability }, { reportRequestError }] = await Promise.all([
import("@app/config"),
import("./src/server/report"),
])
// Nothing has been started when no request has reached the routes yet; there is nothing to report through.
const observe = currentObservability()
if (observe) reportRequestError(observe, error, request, context)
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
import type { NextConfig } from "next"
const nextConfig: NextConfig = {
reactStrictMode: true,
// The server's identity is not information a client needs.
poweredByHeader: false,
// `next dev` writes an AGENTS.md into this folder when an AI coding agent runs it. The rules for
// anyone writing code here are the repository's own AGENTS.md; a second file, with no licence
// header, would also fail `node scripts/licence.mjs --check` on the first `pnpm dev`.
agentRules: false,
// The development badge sits over the bottom-left corner of the page, and the end-to-end suite
// (which runs the shell under `next dev`) cannot click what it covers.
devIndicators: false,
// `forbidden()` and `app/forbidden.tsx`: a member whose role does not allow a page gets a real 403
// (a stranger gets the 404 of not-found.tsx, never this). Still marked experimental by Next.js.
experimental: { authInterrupts: true },
// The workspace root, so the build traces the files it needs from a pnpm store outside this
// package. Without it a standalone build misses @app/config and the kit.
outputFileTracingRoot: new URL("../..", import.meta.url).pathname,
// The brand package draws share images and checks logos with WebAssembly (resvg, libwebp) and reads its
// fonts and wasm from its own folder. It runs from node_modules, not from inside a bundle, so those
// files are where it expects them.
serverExternalPackages: ["@conusai-kit/brand"],
// One image serves every environment. Compose serves its immutable, content-hashed chunks
// directly at /_next/static. Do not apply the platform's deployment asset prefix at startup:
// Turbopack compiled its chunk URLs at build time, so changing only the server's URLs leaves
// React waiting for chunks that already downloaded under a different name (no hydration).
}
export default nextConfig
+58
View File
@@ -0,0 +1,58 @@
{
"name": "@app/web",
"version": "0.1.0",
"private": true,
"description": "The web shell: routes, layouts and the one HTTP mount every procedure is served through. No domain code.",
"type": "module",
"scripts": {
"build": "next build",
"dev": "next dev",
"start": "next start",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@app/config": "workspace:*",
"@app/modules": "workspace:*",
"@conusai-kit/auth": "catalog:kit",
"@conusai-kit/brand": "catalog:kit",
"@conusai-kit/core": "catalog:kit",
"@conusai-kit/data": "catalog:kit",
"@conusai-kit/data-kysely": "catalog:kit",
"@conusai-kit/files": "catalog:kit",
"@conusai-kit/i18n": "catalog:kit",
"@conusai-kit/jobs-pgboss": "catalog:kit",
"@conusai-kit/platform": "catalog:kit",
"@conusai-kit/proposals": "catalog:kit",
"@conusai-kit/tenancy": "catalog:kit",
"@conusai-kit/ui": "catalog:kit",
"@conusai-sdk/node-sdk": "catalog:",
"@conusai-sdk/observe": "catalog:",
"@conusai-sdk/observe-next": "catalog:",
"@conusai-sdk/observe-node": "catalog:",
"@jsquash/webp": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@resvg/resvg-wasm": "catalog:",
"hono": "catalog:",
"kysely": "catalog:",
"next": "catalog:",
"pg": "catalog:",
"pg-boss": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"wawoff2": "catalog:",
"zod": "catalog:",
"@conusai-kit/vector-qdrant": "catalog:kit",
"@qdrant/js-client-rest": "catalog:",
"@conusai-kit/guides": "catalog:kit",
"@conusai-kit/support": "catalog:kit",
"@conusai-kit/auth-convex-better-auth": "catalog:kit"
},
"devDependencies": {
"@types/node": "26.6.5",
"@types/pg": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "5.9.3"
}
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// One nonce per request, and the security headers of spec §6.4 on every response.
//
// `proxy.ts` is Next.js 16's name for what used to be `middleware.ts`; the older name still
// works and warns, so the template uses the current one.
//
// The policy is also set on the *request* headers, which is how Next.js finds the nonce for the
// scripts it injects itself; `x-nonce` is there for a layout that renders a script of its own.
import { type NextRequest, NextResponse } from "next/server"
import { contentSecurityPolicy, securityHeaders } from "./src/server/headers"
export default function proxy(request: NextRequest): NextResponse {
const nonce = Buffer.from(globalThis.crypto.randomUUID()).toString("base64")
const secure = request.nextUrl.protocol === "https:"
const development = process.env.NODE_ENV === "development"
const policy = contentSecurityPolicy({ nonce, development })
const headers = new Headers(request.headers)
headers.set("x-nonce", nonce)
// Where this request is, for a page that has to send a visitor to sign in and bring them back: a
// server component is not given its own URL.
headers.set("x-path", `${request.nextUrl.pathname}${request.nextUrl.search}`)
headers.set("content-security-policy", policy)
const response = NextResponse.next({ request: { headers } })
for (const [name, value] of Object.entries(securityHeaders({ nonce, secure, development }))) {
response.headers.set(name, value)
}
return response
}
export const config = {
matcher: [
{
// Everything but the build's own immutable assets, which carry no HTML and no script of their own.
source: "/((?!_next/static|_next/image|favicon.svg).*)",
// ...and but a request whose body is a stream of bytes (`PUT /api/v1/<procedure>` of a
// procedure declared with `.stream(field)`, the pieces of a file upload). Next.js buffers the
// body of every request this proxy runs for and drops what is past `proxyClientMaxBodySize`
// (10 MB), so a 30 MB file would reach its route cut short and fail its length check. The
// response of that route is JSON, which needs no nonce and no policy for a page; the router
// sets the headers that matter for JSON itself (router.ts).
missing: [{ type: "header", key: "content-type", value: "application/octet-stream" }],
},
],
}
+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="App">
<!-- A monochrome placeholder: replace it with your own mark, and the two logos
config/brand.ts names, when you make this app yours (spec §10.2). It carries no
colour of its own, so it reads on a light and on a dark tab strip. -->
<style>
.fg { fill: #111111; }
.bg { fill: #ffffff; }
@media (prefers-color-scheme: dark) {
.fg { fill: #ffffff; }
.bg { fill: #111111; }
}
</style>
<rect class="bg" width="32" height="32" rx="7" />
<path class="fg" d="M9 23V9h4.6c3.3 0 5.4 1.6 5.4 4.3 0 1.9-1 3.2-2.7 3.8L23 23h-3.5l-3.4-5.1h-2V23H9Zm4.1-7.6h1.2c1.4 0 2.2-.6 2.2-1.8s-.8-1.8-2.2-1.8h-1.2v3.6Z" />
</svg>

After

Width:  |  Height:  |  Size: 745 B

+86
View File
@@ -0,0 +1,86 @@
// 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"
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",
}
}
/**
* The SDK's options. Consent is `anonymous` and the modules are the errors the core always has and
* the trace headers: nothing identifies a person, and no analytics, replay or web-vitals module is
* loaded. A product that wants more chooses its own consent policy here -- `opt-in` with a banner
* is the safer start for visitors in the EU (the SDK's README has the table).
*/
export function browserObserveOptions(settings: BrowserObserveSettings): ObserveOptions {
return {
release: settings.release,
environment: settings.environment,
consent: "anonymous",
modules: { traces: true },
}
}
/**
* Starts the SDK when the server turned it on; returns the instance, or nothing. `start` is
* `instrumentationClient` of `@conusai-sdk/observe-next`, which is idempotent -- Next re-runs
* `instrumentation-client.ts` on every hot reload -- and a test's own factory.
*/
export function startBrowserObserve(
document: Pick<Document, "querySelector">,
start: (options: ObserveOptions) => Observe,
): Observe | undefined {
const settings = readBrowserSettings(document)
const started = settings ? start(browserObserveOptions(settings)) : undefined
if (started) holder()[INSTANCE] = started
return started
}
// 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)
}
+428
View File
@@ -0,0 +1,428 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The web process's composition root (spec §2). One registry, one data source, one pair of pools,
// one set of platform clients, built once per process and shared by every request; what belongs to
// a request is built in request.ts, because a tenancy resolver that outlived its request would hand
// a removed member the role they had when it began (kit-tenancy).
//
// One runtime per module graph. Next.js can bundle a route, a layout and `instrumentation.ts` as
// separate graphs, each with its own copy of the kit; a runtime built by one and used by another
// would hand a copy of `@conusai-kit/auth` the data source of another ("entity session is not the
// definition this data source was built with"). In a production build the graphs share their chunks
// and there is one runtime; in development a route may have its own, which is only a second pool.
import {
type AppEnv,
assertBoundByRowLevelSecurity,
loadAppEnv,
type PlatformClients,
PROFILE,
platformClients,
roleFacts,
roleLine,
type ServerObservability,
startObservability,
} from "@app/config"
import { appModules } from "@app/modules"
import { recordSessionRevocation } from "@app/modules/account"
import {
type AppSessionAdapter,
type AuthRoutes,
createAuthRoutes,
createOidcAppSessionAdapter,
createOidcClient,
loadAuthConfig,
type SessionRef,
selectAppSessionAdapter,
} from "@conusai-kit/auth"
import { buildRegistry, Container, type Registry } from "@conusai-kit/core"
import {
DATA_SOURCE,
type DataSource,
entitiesOf,
IDEMPOTENCY,
JOB_QUEUE,
OUTBOX,
SYSTEM_DATA,
type SystemDataSource,
} from "@conusai-kit/data"
import { createKyselyDataSource, type KyselyDataSource } from "@conusai-kit/data-kysely"
import {
createPgBoss,
createPgBossJobQueue,
fromKyselyTransaction,
KIT_JOBS_ENTITIES,
withJobTransaction,
} from "@conusai-kit/jobs-pgboss"
import { observeRegister } from "@conusai-sdk/observe-next/register"
import { ConvexHttpClient } from "convex/browser"
import { makeFunctionReference } from "convex/server"
import { Pool } from "pg"
import { CONVEX_PROCEDURES } from "../../../../convex/generated/procedures"
import { convexBffModules } from "../../../../convex/lib/convexModules.mjs"
// kit:convex-better-auth:begin
import {
type BetterAuthRuntime,
bindBetterAuthAccountEnd,
createBetterAuthRuntime,
deliverAccountEvent,
localAccountFlow,
} from "./better-auth"
// kit:convex-better-auth:end
import { createConvexAuthDataSource } from "./convex-auth-data"
import { deliverConvexAccountEvent } from "./convex-caller" // kit:convex-better-auth
import { revokeConvexSession } from "./convex-revocation"
import { revokeDerivedSessions } from "./derived-sessions"
import { bindWebPorts } from "./ports"
export interface WebRuntime {
readonly env: AppEnv
readonly registry: Registry
readonly data?: KyselyDataSource
/** The two pools, for the readiness probe: everything else reaches them through the data source. */
readonly pools?: { readonly runtime: Pool; readonly reader: Pool }
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
}
let runtime: WebRuntime | undefined
let checked: Promise<void> | undefined
let authentication: Promise<AuthRoutes> | undefined
let normalizedSession: Promise<AppSessionAdapter> | undefined
let betterAuth: BetterAuthRuntime | undefined // kit:convex-better-auth
let closing = false
/**
* SIGTERM/SIGINT, which the platform sends before it replaces or stops the container: flush the
* queued usage events (an unflushed queue is unbilled usage), send what the observability
* exporters hold, and close the pools, so the shutdown Next.js then performs loses nothing and
* hangs on no connection. The process exit itself stays Next.js's own; this only empties what
* this runtime holds. Registered when the runtime is built -- a module graph that never built one
* has nothing to flush.
*/
function shutdown(signal: string): void {
const current = runtime
if (!current) return
if (closing) return
closing = true
void (async () => {
current.observe.log.info(`web: ${signal}, flushing and closing`)
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 betterAuth?.stop().catch(() => undefined) // kit:convex-better-auth
await current.observe.shutdown().catch(() => undefined)
await current.data?.close().catch(() => undefined)
})()
}
/**
* The process, built on its first request. `instrumentation.ts` parses the deployment contract at
* boot, 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
const env = loadAppEnv("web")
const observe = startObservability(env, { initErrors: observeRegister })
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 current: WebRuntime = {
env,
registry,
root: new Container(),
platform,
observe,
convex: env.convex,
authData,
}
runtime = current
checked = verifyConvexCapabilities(current)
process.once("SIGTERM", () => shutdown("SIGTERM"))
process.once("SIGINT", () => shutdown("SIGINT"))
return current
}
if (!env.postgres) throw new Error("Postgres profile environment was not loaded")
const registry = buildRegistry([...appModules])
const platform = platformClients(env, registry)
const postgres = env.postgres
if (!postgres) throw new Error("Postgres profile environment was not loaded")
const entities = [...entitiesOf(registry, PROFILE), ...KIT_JOBS_ENTITIES]
const pools = {
// The runtime role: every request, bound by FORCE row-level security (decision AT-D20).
runtime: new Pool({ connectionString: postgres.runtime, max: 10 }),
// The global reader. It is not exempt from row-level security either; what it may read
// across tenants is the three tables the app's own migrations granted it, and nothing else.
reader: new Pool({ connectionString: postgres.reader, max: 4 }),
}
const data = createKyselyDataSource({
entities,
pool: pools.runtime,
readerPool: pools.reader,
// A delete the files module enqueues inside its transaction joins it (kit-files AT-F4).
transactionContext: (trx, run) => withJobTransaction(fromKyselyTransaction(trx), run),
})
const boss = createPgBoss({ connectionString: postgres.runtime })
const root = new Container()
root.value(DATA_SOURCE, data)
root.value(SYSTEM_DATA, data)
root.value(OUTBOX, data.outbox)
root.value(IDEMPOTENCY, data.idempotency)
// The platform's services, bound to the tokens a module resolves them by (ports.ts).
bindWebPorts(root, platform, registry)
// An account deletion ends the person's Better Auth account too (AUTH_PROVIDER=convex-better-auth).
bindBetterAuthAccountEnd(root) // kit:convex-better-auth
// A request enqueues; it never runs a job. pg-boss connects on the first enqueue, so a
// deployment whose queue is idle opens no connection for it.
root.register(JOB_QUEUE, () => createPgBossJobQueue({ boss }))
runtime = { env, registry, data, pools, root, platform, observe }
checked = announceRoles(runtime)
process.once("SIGTERM", () => shutdown("SIGTERM"))
process.once("SIGINT", () => shutdown("SIGINT"))
return runtime
}
/**
* 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()
}
/** 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")
}
}
/**
* What the database says about the two connections this process opened (decision AT-D20). It runs
* once, beside the first use of the runtime, and it ends the process when a connection is not
* bound by row-level security: a web container that holds the owner's login reads every tenant's
* rows from code that looks correct, and serving requests in that state is worse than not serving.
*/
async function announceRoles({ pools, observe }: WebRuntime): Promise<void> {
if (!pools) return
try {
const [runtimeRole, readerRole] = await Promise.all([roleFacts(pools.runtime), roleFacts(pools.reader)])
assertBoundByRowLevelSecurity(runtimeRole, "POSTGRES_URL")
assertBoundByRowLevelSecurity(readerRole, "POSTGRES_READER_URL")
observe.log.info(roleLine("web", runtimeRole))
observe.log.info(roleLine("web reader", readerRole))
} catch (error) {
// A web container that holds the owner's login reads every tenant's rows from code that looks
// correct. It stops, and the platform's health check takes it out of rotation.
observe.log.error(`web: ${error instanceof Error ? error.message : String(error)}`)
process.exit(78)
}
}
/**
* The sign-in backend-for-frontend (spec §7), built on first use rather than at boot, so
* `/api/health` answers without it. The OIDC client is handed over as a factory: discovery is a
* network call and the provider may not exist yet (KI-199), so it runs on the first sign-in --
* never on a render -- and kit-auth retries a failed discovery rather than caching it. A
* rejected build is likewise not memoized: a request that raced a broken configuration must not
* 500 every page that follows it.
*/
export function authRoutes(): Promise<AuthRoutes> {
authentication ??= (async () => {
const current = webRuntime()
const data = current.authData ?? current.data
if (!data) throw new Error("The selected app profile has no auth data source")
const config = loadAuthConfig()
const oidc = config.oidc
return createAuthRoutes({
config,
data,
system: data,
...(oidc ? { oidc: () => createOidcClient(oidc) } : {}),
onSessionRevoked: (ref) => sessionRevoked(current, ref),
})
})().catch((error) => {
authentication = undefined
throw error
})
return authentication
}
/**
* 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
return revokeDerivedSessions(
{
registry: current.registry,
root: current.root,
log: current.observe.log,
...(current.data
? {
durable: (session: string) => recordSessionRevocation(current.root, session, { log: current.observe.log }),
}
: convex
? { durable: (session: string) => revokeConvexSession(convex, session) }
: {}),
},
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(): 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 = 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 deliverAccountEvent(
{
accountFlow: convex
? (flowEvent) => deliverConvexAccountEvent(convex, flowEvent)
: 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: the OIDC adapter (which also serves
* dev-login rows outside production) or the Convex Better Auth adapter. A provider this build has no
* adapter for (an app pruned with `kit template:prune --auth oidc` that selects convex-better-auth)
* refuses every session request by name instead of falling back to OIDC.
*/
export function appSession(): Promise<AppSessionAdapter> {
normalizedSession ??= (async () => {
const routes = await authRoutes()
const config = loadAuthConfig()
return selectAppSessionAdapter(config, {
oidc: () => createOidcAppSessionAdapter({ routes, config }),
"convex-better-auth": () => betterAuthRuntime().adapter, // kit:convex-better-auth
})
})().catch((error) => {
normalizedSession = undefined
throw error
})
return normalizedSession
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The one convention that makes an audit event readable back per organization (spec §9.4). The
// client that writes it is the platform's (config/src/platform.ts, `platformClients().audit`); what
// is here is what goes in its `target`.
/**
* The target of an audit event, and the only two forms this app writes.
*
* The platform's audit log is one deployment's log, and a deployment serves every organization of
* this app, so the organization has to be in the event itself or the in-app viewer could not show
* one organization's events without showing another's. Every event therefore targets the
* organization, and a resource inside it is `<org>/<resource id>`. The transport writes the slug
* form, because a call's input carries slugs; an outbox subscriber writes the id form, because an
* event payload carries the org id. Both are globally unique and immutable, and `isOwnedBy` is the
* same rule read backwards for both, so the writes and the viewer's filter cannot drift apart.
*/
export function auditTarget(orgSlug: string, resource?: { type: string; id: string }): { id: string; type: string } {
return resource ? { id: `${orgSlug}/${resource.id}`, type: resource.type } : { id: orgSlug, type: "organization" }
}
export const isOwnedBy = (org: { slug: string; id: string }, resourceId: string | null | undefined): boolean =>
resourceId === org.slug ||
resourceId === org.id ||
(resourceId?.startsWith(`${org.slug}/`) ?? false) ||
(resourceId?.startsWith(`${org.id}/`) ?? false)
/** The organization a call names, which is the slug in its input (kit-tenancy: inputs carry slugs). */
export function orgSlugOf(input: unknown, output: unknown): string | undefined {
const named = (value: unknown): string | undefined => {
if (typeof value !== "object" || value === null) return undefined
const slug = (value as Record<string, unknown>).org ?? (value as Record<string, unknown>).slug
return typeof slug === "string" && slug.length > 0 ? slug : undefined
}
return named(input) ?? named(output)
}
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a failed sign-in becomes (spec §7). @conusai-kit/auth's routes throw: a callback with no
// transaction cookie, a state that is not this browser's, a code the provider refuses. Left alone,
// each is a 500 page or a blank response in the middle of a redirect chain. Here every failure ends
// at the sign-in page with a short code in the URL, which the page words in the person's language.
//
// The code is a fixed word and the detail never travels: an error from the provider can quote the
// authorization code, the state or an address, and the URL of a redirect is logged by everything
// between the browser and this app.
import { AppError } from "@conusai-kit/core"
export const AUTH_ERRORS = ["sessionExpired", "noTransaction", "denied", "provider", "failed"] as const
export type AuthError = (typeof AUTH_ERRORS)[number]
/** 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",
}
/** A code from a URL, or nothing: only the five words above are ever shown. */
export function parseAuthError(value: string | null | undefined): AuthError | undefined {
return AUTH_ERRORS.find((code) => code === value)
}
/**
* Which of the five a failure is. The provider's own `?error=` (the person said no, or the provider
* did) is `denied`; the kit's "no sign-in is in progress" is `noTransaction`; any other refusal the
* app raised is `failed`; and anything that is not the app's own error came from the exchange with
* the provider.
*/
export function authErrorOf(error: unknown, url: URL): AuthError {
if (url.searchParams.has("error")) return "denied"
if (error instanceof AppError) {
if (error.status === 401 && /no sign-in is in progress/.test(error.message)) return "noTransaction"
// A deployment with no identity provider has nothing to sign in to: that is not an ended session.
if (error.status === 401 && /no identity provider/.test(error.message)) return "failed"
if (error.status === 401) return "sessionExpired"
return "failed"
}
return "provider"
}
/** The sign-in page for an error, keeping where the person was going. */
export function signInErrorHref(code: AuthError, returnTo?: string | null): string {
const query = new URLSearchParams({ error: code })
const safe = safeReturnTo(returnTo)
if (safe !== "/") query.set("return_to", safe)
return `/auth/sign-in?${query.toString()}`
}
/** Only a path inside this app: an absolute or protocol-relative URL here would be an open redirect, and so is any backslash — browsers normalize `/\evil.com` to `//evil.com`. */
export const safeReturnTo = (value: string | null | undefined): string => {
if (!value?.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/"
try {
return new URL(value, "http://localhost").origin === "http://localhost" ? value : "/"
} catch {
return "/"
}
}
+31
View File
@@ -0,0 +1,31 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Runs one of @conusai-kit/auth's route handlers and keeps its failures from reaching the browser as
// a 500 in the middle of a redirect chain (auth-errors.ts says what they become instead).
import { AppError } from "@conusai-kit/core"
import { webRuntime } from "./app"
import { authErrorOf, safeReturnTo, signInErrorHref } from "./auth-errors"
/** A 302 to a path of this app. */
export const redirectTo = (location: string): Response => new Response(null, { status: 302, headers: { location } })
export async function authResponse(request: Request, run: () => Promise<Response>): Promise<Response> {
try {
return await run()
} catch (error) {
const url = new URL(request.url)
const code = authErrorOf(error, url)
const { observe } = webRuntime()
// The code and the error's class: the message of a provider error can quote what it was sent.
observe.log.warn("sign-in failed", {
signin_error: code,
error: error instanceof Error ? error.name : "unknown",
// An AppError's status and code are the app's own words, never the provider's.
...(error instanceof AppError ? { status: error.status, code: error.code } : {}),
})
// A refusal the visitor caused is not an incident; a failure of the exchange with the provider is.
if (!(error instanceof AppError) && !url.searchParams.has("error"))
observe.captureException(error, { route: "auth" })
return redirectTo(signInErrorHref(code, safeReturnTo(url.searchParams.get("return_to"))))
}
}
@@ -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 (app/api/auth), 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 app/api/auth/[...all]: 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))
}
+49
View File
@@ -0,0 +1,49 @@
// 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 { readFile } from "node:fs/promises"
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" }
/** The app's public folder: `next start` and `next 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")
return { contentType, bytes: new Uint8Array(await readFile(/*turbopackIgnore: true*/ file)) }
}
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")),
})
+113
View File
@@ -0,0 +1,113 @@
// 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" | "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 }),
},
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
}
+242
View File
@@ -0,0 +1,242 @@
// 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.
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 { 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
}
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 = new ConvexHttpClient(convex.url)
;(client as AdminAuthClient).setAdminAuth(convex.adminKey)
try {
await client.action(makeFunctionReference<"action">("account:providerEvent") as never, event as never)
} catch (error) {
throw asProblem(error)
}
}
export function createConvexCaller(options: {
url: string
adminKey: string
principal: Principal | null
objectStore: PlatformObjectStore
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). */
credential?: AccountCredential
/** The request's app session (kit-auth `sessionRefKey`): a derived service session is minted for it. */
identitySession?: string
}): TransportCaller {
const client = new ConvexHttpClient(options.url)
if (options.principal) {
const subject = options.principal.actor.userId
;(client as AdminAuthClient).setAdminAuth(options.adminKey, {
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 } : {}),
})
} else {
;(client as AdminAuthClient).setAdminAuth(options.adminKey)
}
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)
case "write":
return await client.mutation(fn, args 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
}
+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)
}
}
+57
View File
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// CSRF for the one credential that a browser sends on its own: the session cookie (spec §6.4).
//
// A synchronizer token, not a readable cookie: the token is an HMAC of the session id under the
// app's session secret, rendered into the page, and sent back in `X-CSRF-Token`. Nothing a script
// on another origin can read is involved, and a bearer or API-key caller is not asked for one,
// because no browser attaches those by itself.
import { createHmac, timingSafeEqual } from "node:crypto"
import {
isInteractiveSession,
loadAuthConfig,
type Principal,
sessionBindingOf,
sessionRefKey,
} from "@conusai-kit/auth"
import { forbidden } from "@conusai-kit/core"
export const CSRF_HEADER = "x-csrf-token"
export function csrfToken(sessionId: string): string {
return createHmac("sha256", loadAuthConfig().session.secret).update(`csrf:${sessionId}`).digest("base64url")
}
/** The session a cookie-borne principal is bound to; a malformed session principal throws (kit-auth). */
export function csrfSessionId(principal: Principal | null): string | undefined {
const binding = principal ? sessionBindingOf(principal) : null
if (!binding) return undefined
return binding.kind === "kit-session" ? binding.sessionId : sessionRefKey(binding.sessionRef)
}
const equal = (a: string, b: string): boolean => {
const left = Buffer.from(a)
const right = Buffer.from(b)
return left.length === right.length && timingSafeEqual(left, right)
}
/**
* Refuses a state-changing request from a browser that did not come from this app's own page.
* Two independent checks, because either one alone has a hole: the fetch metadata a modern
* browser sends, and the token only this app's own page carries.
*/
export function requireCsrf(request: Request, principal: Principal | null): void {
if (request.method === "GET" || request.method === "HEAD") return
// Every browser session method, by kit-auth's exhaustive check: only a bearer or API key is exempt.
if (!principal || !isInteractiveSession(principal)) return
const site = request.headers.get("sec-fetch-site")
if (site !== null && site !== "same-origin") {
throw forbidden(`a ${request.method} from ${site} is refused; this API is called from this app's own pages`)
}
const sent = request.headers.get(CSRF_HEADER)
const sessionId = csrfSessionId(principal)
if (!sessionId || !sent || !equal(sent, csrfToken(sessionId))) {
throw forbidden(`send the page's ${CSRF_HEADER} with a ${request.method} made with the session cookie`)
}
}
+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`,
)
}
})
}
+50
View File
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a page 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: 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 role does not allow it: the forbidden page, shown only to a member
//
// Anything else is a fault and goes on to the error boundary.
import { AppError, type Role } from "@conusai-kit/core"
import { atLeast } from "@conusai-kit/tenancy"
import { forbidden, notFound, redirect } from "next/navigation"
import { orgRole, type PageContext } from "./page"
/** 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}` : ""}`
}
/** 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) notFound()
if (failure.status === 403) forbidden()
if (failure.status === 401) redirect(signInHref(path))
}
throw failure
}
/**
* Requires a role 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 below `minimum` gets the forbidden page.
*/
export async function requireRole(page: PageContext, org: string, minimum: Role): Promise<Role> {
const role = await orFallback(page.path, () => orgRole(page, org))
if (role === undefined) notFound()
if (!atLeast(role, minimum)) forbidden()
return role
}
+62
View File
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The response headers every page and every API answer carries (spec §6.4).
//
// The content security policy names a nonce rather than allowing inline scripts, and the nonce is
// minted per request in middleware.ts. `frame-ancestors 'none'` is the one that cannot be set
// from a meta tag, which is why the policy is a header. No external origin is allowed at all: a
// font, an icon and a script all come from this app (self-hosted D20, check-self-hosted.sh).
export interface HeaderOptions {
nonce: string
/**
* `next dev` only. React's development build rebuilds call stacks with `eval`, and reports an error on
* every page -- in any developer's console and in the end-to-end suite's browser -- unless the policy
* allows it. A production build never evaluates a string, and its policy never says it may.
*/
development?: boolean
/** `Strict-Transport-Security` is sent over TLS only; a dev server on plain HTTP would pin itself. */
secure: boolean
}
export function contentSecurityPolicy({
nonce,
development = false,
}: Pick<HeaderOptions, "nonce" | "development">): string {
return [
"default-src 'self'",
// `strict-dynamic` is what lets the nonced bootstrap script load the shell's own chunks, which
// is how a Next.js page hydrates. The trade-off is explicit: with it, a script this app
// already trusts may load a further script from anywhere, and the `'self'` beside it is
// ignored by a browser that understands `strict-dynamic`. What it buys is that nothing runs
// without the nonce -- an injected `<script>` tag does not, which is the attack it exists for.
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${development ? " 'unsafe-eval'" : ""}`,
// The style attribute the brand layer writes (`--brand-*`) needs 'unsafe-inline' for styles
// alone; no script is ever allowed inline.
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
"frame-src 'none'",
"worker-src 'self' blob:",
"manifest-src 'self'",
"upgrade-insecure-requests",
].join("; ")
}
export function securityHeaders(options: HeaderOptions): Record<string, string> {
return {
"content-security-policy": contentSecurityPolicy(options),
"referrer-policy": "strict-origin-when-cross-origin",
"x-content-type-options": "nosniff",
"x-frame-options": "DENY",
"cross-origin-opener-policy": "same-origin",
"cross-origin-resource-policy": "same-origin",
// Nothing in this app needs a camera, a microphone or a location.
"permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
...(options.secure ? { "strict-transport-security": "max-age=63072000; includeSubDomains; preload" } : {}),
}
}
+121
View File
@@ -0,0 +1,121 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// MCP transport is provided by the official SDK. Each request receives a fresh server bound to
// the same request caller used by /api/v1; the kernel remains the sole authorization authority.
import type { Principal } from "@conusai-kit/auth"
import type { Registry } from "@conusai-kit/core"
import { AppError } from "@conusai-kit/core"
import { createMcpHandler as createSdkMcpHandler, McpServer } from "@modelcontextprotocol/server"
import { requireCsrf } from "./csrf"
import type { RequestContext } from "./request"
type McpContext = Pick<RequestContext, "principal" | "caller">
type McpLog = {
info(message: string, attributes?: Record<string, string | number>): void
error(message: string, attributes?: Record<string, string | number>): void
}
export interface McpHandlerOptions {
registry: Registry
contextFor(request: Request, requestId: string): Promise<McpContext>
checkCsrf?: (request: Request, principal: Principal | null) => void
observe?: { log: McpLog; captureException(error: unknown, attributes?: Record<string, string>): void }
}
const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff" } as const
const httpFailure = (status: number, code: string, title: string): Response =>
new Response(JSON.stringify({ type: `/problems/${code}`, title, status, code }), {
status,
headers: {
"content-type": "application/problem+json",
...HEADERS,
...(status === 401 ? { "www-authenticate": "Bearer" } : {}),
},
})
function safeToolError(error: unknown): string {
// Never return exception details: they may contain a query, a tenant value or a credential.
if (error instanceof AppError && error.status < 500) return "The tool request was refused."
return "The tool could not be completed."
}
export function createMcpHandler(options: McpHandlerOptions) {
const checkCsrf = options.checkCsrf ?? requireCsrf
return async (request: Request, requestId: string): Promise<Response> => {
let context: McpContext
try {
context = await options.contextFor(request, requestId)
if (!context.principal) return httpFailure(401, "unauthorized", "Authentication is required")
checkCsrf(request, context.principal)
} catch (error) {
const status = error instanceof AppError ? error.status : 500
const code = error instanceof AppError ? error.code : "internal"
const title = error instanceof AppError ? error.problem.title : "Internal error"
if (status >= 500) {
options.observe?.captureException(error, { protocol: "mcp", requestId })
options.observe?.log.error("mcp authentication failed", { "http.response.status_code": status })
}
return httpFailure(status, code, title)
}
const sdkHandler = createSdkMcpHandler(
() => {
const server = new McpServer(
{ name: "conusai-app", version: "0.1.0" },
{ capabilities: { tools: { listChanged: false } } },
)
for (const [name, entry] of options.registry.procedures) {
if (!entry.def.tool) continue
server.registerTool(
name,
{
description: `Read data through the ${name} procedure.`,
inputSchema: entry.def.input,
outputSchema: entry.def.output,
},
async (args: unknown) => {
const startedAt = Date.now()
try {
const output: unknown = await context.caller.callByName(name, args)
options.observe?.log.info("mcp tool", {
"conusai.procedure": name,
"http.response.status_code": 200,
duration_ms: Date.now() - startedAt,
})
return {
content: [{ type: "text" as const, text: JSON.stringify(output ?? null) }],
structuredContent:
typeof output === "object" && output !== null && !Array.isArray(output) ? output : undefined,
}
} catch (error) {
if (!(error instanceof AppError) || error.status >= 500) {
options.observe?.captureException(error, { procedure: name })
options.observe?.log.error("mcp tool failed", { "conusai.procedure": name })
}
return { content: [{ type: "text" as const, text: safeToolError(error) }], isError: true }
}
},
)
}
return server
},
{ legacy: "stateless" },
)
try {
const response = await sdkHandler.fetch(request)
const headers = new Headers(response.headers)
for (const [name, value] of Object.entries(HEADERS)) headers.set(name, value)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
} catch (error) {
options.observe?.captureException(error, { protocol: "mcp", requestId })
options.observe?.log.error("mcp request failed", { "http.response.status_code": 500 })
return httpFailure(500, "internal", "Internal error")
} finally {
await sdkHandler.close()
}
}
}
+205
View File
@@ -0,0 +1,205 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The OpenAPI 3.1 document, generated from the registry (spec §6.2).
//
// There is no second description of a procedure anywhere: the name, the kind, the scope, the
// permission and both zod schemas come from the manifests, and zod 4 converts a schema to JSON
// Schema itself. The committed openapi.json is this function's output, and test/openapi.test.ts
// fails when the two differ -- so a changed input schema is a visible API change in review.
import type { Registry } from "@conusai-kit/core"
import { z } from "zod"
export const API_PREFIX = "/api/v1"
/** The problem+json model every refusal uses (kit-core's errors.ts). */
const problemSchema = {
type: "object",
required: ["type", "title", "status", "code"],
properties: {
type: { type: "string", description: "An app-relative URI reference, /problems/<code>." },
title: { type: "string" },
status: { type: "integer" },
code: { type: "string" },
detail: { type: "string" },
errors: {
type: "array",
items: {
type: "object",
required: ["path", "message"],
properties: { path: { type: "string" }, message: { type: "string" } },
},
},
retryAfter: { type: "integer", description: "Seconds, for 429 and 503." },
},
} as const
const problemResponse = (description: string) => ({
description,
content: { "application/problem+json": { schema: { $ref: "#/components/schemas/Problem" } } },
})
const jsonSchemaOf = (schema: z.ZodType, io: "input" | "output"): unknown =>
z.toJSONSchema(schema, { target: "draft-2020-12", io, unrepresentable: "any", reused: "inline" })
/**
* What a procedure with a streamed body (kit-core `.stream(field)`) is on the wire: a `PUT` whose
* body is the bytes and whose query parameters are the rest of the input. The schema of the input
* still comes from the contract; the body field is the one that is not a parameter.
*/
function streamedOperation(def: { input: z.ZodType; stream?: string | undefined }) {
if (def.stream === undefined) return undefined
const input = jsonSchemaOf(def.input, "input") as {
properties?: Record<string, unknown>
required?: string[]
}
const required = new Set(input.required ?? [])
return {
parameters: Object.entries(input.properties ?? {})
.filter(([name]) => name !== def.stream)
.map(([name, schema]) => ({ name, in: "query", required: required.has(name), schema })),
requestBody: {
required: true,
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
description: "The bytes, sent as they are. Their length must be the one the upload declared.",
},
}
}
export interface OpenApiInfo {
title: string
version: string
description?: string
}
export function openapiDocument(registry: Registry, info: OpenApiInfo): Record<string, unknown> {
const paths: Record<string, unknown> = {}
for (const name of [...registry.procedures.keys()].sort()) {
const entry = registry.procedures.get(name)
if (!entry) continue
const { def, module } = entry
const authenticated = def.scope !== "public"
const streamed = streamedOperation(def)
const downloadInput = def.download
? (jsonSchemaOf(def.input, "input") as { properties?: Record<string, unknown>; required?: string[] })
: undefined
const downloadRequired = new Set(downloadInput?.required ?? [])
const responseContent = def.download
? { "application/octet-stream": { schema: { type: "string", format: "binary" } } }
: { "application/json": { schema: jsonSchemaOf(def.output, "output") } }
paths[`${API_PREFIX}/${name}`] = {
[def.download ? "get" : streamed ? "put" : "post"]: {
operationId: name,
tags: [module],
summary: `${def.kind} procedure on the ${def.scope} scope`,
description: [
`Kind: ${def.kind}. Scope: ${def.scope}. Permission: ${def.permission}.`,
def.idempotent ? "Takes an Idempotency-Key header." : undefined,
def.audit ? `Audit event: ${def.audit}.` : undefined,
def.usage ? `Usage meter: ${def.usage}.` : undefined,
def.tool ? "Exposed as an MCP tool." : undefined,
]
.filter(Boolean)
.join(" "),
...(authenticated ? { security: [{ session: [] }, { bearer: [] }, { apiKey: [] }] } : { security: [{}] }),
parameters: [
...(def.idempotent
? [
{
name: "Idempotency-Key",
in: "header",
required: true,
schema: { type: "string", minLength: 8, maxLength: 200 },
description:
"The first answer for this key is replayed, so a retry is a retry and not a second write.",
},
]
: []),
{
name: "X-CSRF-Token",
in: "header",
required: false,
schema: { type: "string" },
description: "Required when the credential is the session cookie; not used by bearer or API-key callers.",
},
...(streamed?.parameters ?? []),
],
...(!def.download
? {
requestBody: streamed?.requestBody ?? {
required: true,
content: { "application/json": { schema: jsonSchemaOf(def.input, "input") } },
},
}
: {}),
...(def.download
? {
parameters: [
...(def.idempotent
? [
{
name: "Idempotency-Key",
in: "header",
required: true,
schema: { type: "string", minLength: 8, maxLength: 200 },
description: "The first answer for this key is replayed.",
},
]
: []),
...Object.entries(downloadInput?.properties ?? {}).map(([name, schema]) => ({
name,
in: "query",
required: downloadRequired.has(name),
schema,
})),
],
}
: {}),
responses: {
"200": {
description: def.download
? "The file bytes, streamed with the stored filename."
: "The procedure's output.",
content: responseContent,
},
"400": problemResponse("The request could not be read."),
"401": problemResponse("No credential, or one that is no longer valid."),
"404": problemResponse(
"No such procedure in this scope: another tenant's ids answer the same way as a missing permission.",
),
"409": problemResponse("A conflict, such as a taken slug or a stale version."),
"422": problemResponse("The input does not match the procedure's schema."),
"429": problemResponse("Rate limited; Retry-After says when."),
},
},
}
}
return {
openapi: "3.1.0",
info,
servers: [{ url: "/", description: "This deployment." }],
paths,
components: {
schemas: { Problem: problemSchema },
securitySchemes: {
session: {
type: "apiKey",
in: "cookie",
name: "conusai_session",
description: "The sealed session cookie a browser holds.",
},
bearer: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
description: "An OIDC access token for this app's audience.",
},
apiKey: { type: "http", scheme: "bearer", description: "An organization API key, cak_<prefix>_<secret>." },
},
},
}
}
/** The document as it is served and as it is committed: two spaces, one trailing newline. */
export const openapiJson = (registry: Registry, info: OpenApiInfo): string =>
`${JSON.stringify(openapiDocument(registry, info), null, 2)}\n`
+97
View File
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What a server component gets: the request's caller, its language and theme, and its CSRF token.
//
// A page has no `Request` of its own in the App Router, so one is built from the request's
// headers: the session cookie is in them, which is all @conusai-kit/auth reads. Every page then
// takes exactly the path a route handler takes -- one caller, one set of rules (spec §6.4).
import { appBrand, PROFILE, publicUrlOf } from "@app/config"
import type { Actor, Container, Role, TransportCaller } from "@conusai-kit/core"
import { createI18n, type I18n } from "@conusai-kit/i18n"
import { atLeast, TENANCY } from "@conusai-kit/tenancy"
import { headers } from "next/headers"
import { cache } from "react"
import { appCatalogues } from "../ui/messages"
import { webRuntime } from "./app"
import { csrfSessionId, csrfToken } from "./csrf"
import { localeOf, type Theme, themeOf } from "./preferences"
import { requestContext } from "./request"
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
readonly csrf?: string
readonly i18n: I18n
readonly theme: Theme
/** The path and query of this request, as the proxy saw them: 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
}
/**
* Memoized per request by React's `cache`: a layout and the page inside it both ask for the
* context, and without this each would read the session row and build its own resolver.
*/
export const pageContext = cache(async (): Promise<PageContext> => {
const incoming = await headers()
const request = new Request(`${publicUrlOf()}/`, {
headers: new Headers(incoming),
})
const { caller, container, principal, user } = await requestContext(request)
const { observe } = 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 }),
...(csrf ? { csrf: csrfToken(csrf) } : {}),
i18n: createI18n(locale, appCatalogues),
theme: themeOf(incoming.get("cookie")),
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 role this actor holds in an organization, through the same resolver every procedure uses.
*
* A page that is not a procedure -- the audit viewer is the one in this scaffold -- still has to
* answer "may this person see it", and this is the one way to ask: the resolver's own reads are
* the membership check, so a non-member gets NotFound here exactly as they would from a
* procedure (spec §3.3).
*/
export async function orgRole(page: PageContext, org: string): Promise<Role | undefined> {
if (!page.actor) return undefined
if (PROFILE === "convex") {
const me = (await page.caller.callByName("tenancy.me.get", undefined)) as {
organizations: ReadonlyArray<{ slug: string; role: Role }>
}
return me.organizations.find((entry) => entry.slug === org)?.role
}
const tenancy = page.container.resolve(TENANCY)
const scope = await tenancy.resolveScope("org", { org }, page.actor, page.pinnedOrgId)
return tenancy.roleFor(scope)
}
export const holdsAtLeast = async (page: PageContext, org: string, minimum: Role): Promise<boolean> =>
atLeast(await orgRole(page, org), minimum)
+163
View File
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// 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 { excludedPageClassNamesOf, NO_VECTOR_STORE_REASON, type PlatformClients } from "@app/config"
import { ACCOUNT_PRIVACY, ACCOUNT_REGISTRY } from "@app/modules/account"
import {
DOCUMENT_EXCLUDED_PAGE_CLASSES,
DOCUMENT_MODEL,
DOCUMENT_OCR,
MODEL_GATEWAY,
pageClassesOf,
sdkDocumentOcr,
} from "@app/modules/documents"
import {
KNOWLEDGE_EMBEDDINGS,
KNOWLEDGE_ENABLEMENT,
KNOWLEDGE_SEARCH,
platformKnowledgeSearch,
sdkDocumentAiPlatform,
unavailableKnowledgeSearch,
vectorKnowledgeSearch,
} from "@app/modules/knowledge"
import type { Container, Registry } from "@conusai-kit/core"
import { DATA_SOURCE, OBJECT_STORE, VECTOR_STORE } from "@conusai-kit/data"
import type { KyselyDataSource } from "@conusai-kit/data-kysely"
import { createKyselyVectorStore } from "@conusai-kit/data-kysely"
import { FILES_REGISTRY, filesDeps, platformObjectStore } from "@conusai-kit/files"
import { GUIDES_PLATFORM, GUIDES_REGISTRY, guidesPlatform } from "@conusai-kit/guides" // kit:guides
import { createKnowledge, createPrivacy } from "@conusai-kit/platform"
import { PROPOSALS_REGISTRY } from "@conusai-kit/proposals"
import { SUPPORT_PLATFORM, supportPlatform } from "@conusai-kit/support" // kit:support
import { DEFAULT_WORKSPACE } from "@conusai-kit/tenancy"
import { createQdrantVectorStore } from "@conusai-kit/vector-qdrant"
import { QdrantClient } from "@qdrant/js-client-rest"
const KNOWLEDGE_COLLECTION = "knowledge"
const knowledgeEmbeddings = (platform: PlatformClients): import("@app/modules/knowledge").KnowledgeEmbeddings => ({
embed: (texts, options) => {
const embed = platform.models.embed
if (!embed) {
throw new Error("the AI gateway has no embeddings model configured (AI_EMBEDDING_MODEL)")
}
return embed.call(platform.models, texts, options)
},
})
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(OBJECT_STORE, platformObjectStore({ blob: platform.objectStore }))
container.value(MODEL_GATEWAY, platform.models)
container.value(DOCUMENT_MODEL, platform.model)
const excluded = excludedPageClassNamesOf()
if (excluded) container.value(DOCUMENT_EXCLUDED_PAGE_CLASSES, pageClassesOf(excluded))
container.value(DOCUMENT_OCR, sdkDocumentOcr(platform.documents))
// 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 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 has no privacy service: left unbound, the procedures answer "unavailable".
if (platform.mode !== "fake") {
const privacy = createPrivacy(platform.deployment)
container.value(ACCOUNT_PRIVACY, { ...privacy, ticketKey: platform.deployment.CONUSAI_API_TOKEN })
}
// The knowledge module: the search service a request may call (the embedding is a short gateway
// call, like a model read), and the enablement platform the settings procedures check against.
// The index/remove jobs stay in the worker; a request only writes the outbox event.
container.value(KNOWLEDGE_EMBEDDINGS, {
embed: (texts, options) => {
const embed = platform.models.embed
if (!embed) {
throw new Error("the AI gateway has no embeddings model configured (AI_EMBEDDING_MODEL)")
}
return embed.call(platform.models, texts, options)
},
})
// Lazy: the pgvector adapter resolves the request container's data source, which the runtime
// binds before this function runs but a bare test container does not hold until a search
// actually resolves the port.
container.register(KNOWLEDGE_SEARCH, (c) => {
if (platform.knowledge.backend === "platform") {
return platformKnowledgeSearch(createKnowledge(platform.deployment), filesDeps(c))
}
let store: import("@conusai-kit/data").VectorStore
if (platform.knowledge.vectorStore === "qdrant" && platform.knowledge.qdrantUrl) {
const client = new QdrantClient({
url: platform.knowledge.qdrantUrl,
...(platform.knowledge.qdrantApiKey === undefined ? {} : { apiKey: platform.knowledge.qdrantApiKey }),
})
store = createQdrantVectorStore({
client,
collection: KNOWLEDGE_COLLECTION,
dimensions: platform.knowledge.dimensions,
})
} else if (platform.knowledge.vectorStore === "pgvector") {
const data = c.resolve(DATA_SOURCE) as KyselyDataSource
store = createKyselyVectorStore({
db: data.db,
inScope: (scope, run) => data.inScope(scope, run),
dimensions: platform.knowledge.dimensions,
})
} else {
// No store chosen (no Qdrant linked, pgvector not opted into): readiness says why, and no
// vector store is bound at all.
return unavailableKnowledgeSearch(NO_VECTOR_STORE_REASON)
}
c.value(VECTOR_STORE, store)
return vectorKnowledgeSearch({ embeddings: knowledgeEmbeddings(platform), store })
})
container.value(KNOWLEDGE_ENABLEMENT, knowledgeEnablement(platform))
// 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.value(
GUIDES_PLATFORM,
guidesPlatform({
apiUrl: platform.deployment.CONUSAI_API_URL,
token: platform.deployment.CONUSAI_API_TOKEN,
fake: platform.mode === "fake",
}),
)
// kit:guides:end
// 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.value(
SUPPORT_PLATFORM,
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
}
function knowledgeEnablement(platform: PlatformClients) {
// The enablement flow runs against the platform's own APIs with the deployment credentials.
// The workspace the app links managed services into is the one its own project lives in; the
// operator-configured endpoint, when set, wins over the managed link.
return sdkDocumentAiPlatform({
apiUrl: platform.deployment.CONUSAI_API_URL,
token: platform.deployment.CONUSAI_API_TOKEN,
orgSlug: platform.deployment.CONUSAI_ORG_SLUG?.trim() || platform.deployment.CONUSAI_ORG_ID?.trim() || "dev",
workspaceSlug: DEFAULT_WORKSPACE.slug,
})
}
+58
View File
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The two choices a person makes about how the app looks to them: the language and the theme.
//
// 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 is the spec's (§10.4): the person's setting, then the app's
// default, then the browser's Accept-Language -- and for a visitor the cookie takes
// the place of the person's setting.
// 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.
//
// Neither cookie holds anything but a short fixed word, so neither needs to be signed or sealed.
import { type Locale, negotiateLocale } from "@conusai-kit/i18n"
import { LOCALE_COOKIE, THEME_COOKIE, type Theme } 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; the brand is dark-first, so anything else is dark. */
export function themeOf(cookieHeader: string | null | undefined): Theme {
return cookieValue(cookieHeader, THEME_COOKIE) === "light" ? "light" : "dark"
}
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)
return negotiateLocale({
...(chosen ? { userLocale: chosen } : {}),
organizationLocale: inputs.appLocale,
...(inputs.acceptLanguage ? { acceptLanguage: inputs.acceptLanguage } : {}),
})
}
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The per-procedure rate limit of spec §6.2: a token bucket per actor, per organization and per
// bucket name, so one tenant's burst cannot spend another's allowance.
//
// It counts in this process. A deployment with more than one web replica therefore allows up to
// `replicas x limit`, which is the honest limit of an in-memory counter; the shared counter is
// the `Cache` port over Valkey, and it arrives with the data-service adapters (plan item AT3.5).
// Until then this is the wall that stops a loop in a client, not a distributed quota.
import { rateLimited } from "@conusai-kit/core"
export interface Bucket {
/** Tokens per window. */
readonly limit: number
readonly windowSeconds: number
}
export const BUCKETS: Readonly<Record<string, Bucket>> = {
default: { limit: 120, windowSeconds: 60 },
/** A write costs more than a read, and a burst of them is what a runaway client does. */
write: { limit: 40, windowSeconds: 60 },
/** Anything that calls out to AI, OCR or storage (spec §9). */
effect: { limit: 20, windowSeconds: 60 },
/**
* The three calls of an upload (begin, one per piece, complete): a 100 MB file in 16 MiB pieces is
* eight calls, so a person uploading a handful of files in a minute must not meet the `effect`
* bucket, while a loop that never stops still meets this one.
*/
uploads: { limit: 120, windowSeconds: 60 },
/** The account export and deletion: a person does each a few times, never in a loop. */
account: { limit: 10, windowSeconds: 60 },
/** In-app guides: a bootstrap per page and a batch of events every few seconds. */ // kit:guides
guides: { limit: 120, windowSeconds: 60 }, // kit:guides
/** Support: the widget's configuration and session per page, and its messages through the proxy. */ // kit:support
support: { limit: 120, windowSeconds: 60 }, // kit:support
}
interface Window {
count: number
resetAt: number
}
const windows = new Map<string, Window>()
/** When the map was last swept: the pass is O(N), so it cannot run on every request of a busy process. */
let sweptAt = 0
/**
* Dropped once a window has passed, so an idle tenant leaves nothing behind. The sweep is at most
* one pass a minute while the map is merely large, and immediate when it is huge (50,000 distinct
* actor/organization/bucket keys in a minute is an attack or a bug, not traffic).
*/
function sweep(now: number): void {
if (windows.size < 10_000) return
if (windows.size < 50_000 && now - sweptAt < 60_000) return
sweptAt = now
for (const [key, window] of windows) if (window.resetAt <= now) windows.delete(key)
}
export function take(key: string, bucket: Bucket, now: number = Date.now()): void {
sweep(now)
const window = windows.get(key)
if (!window || window.resetAt <= now) {
windows.set(key, { count: 1, resetAt: now + bucket.windowSeconds * 1000 })
return
}
window.count += 1
if (window.count > bucket.limit) {
throw rateLimited(
Math.ceil((window.resetAt - now) / 1000),
`too many requests for the ${bucket.limit}/${bucket.windowSeconds}s bucket`,
)
}
}
/** The bucket a procedure counts against: its own, or the one its kind implies. */
export const bucketFor = (route: { kind: string; rateLimit?: string }): Bucket =>
(route.rateLimit ? BUCKETS[route.rateLimit] : undefined) ??
(route.kind === "effect" ? BUCKETS.effect : route.kind === "write" ? BUCKETS.write : BUCKETS.default) ?? {
limit: 120,
windowSeconds: 60,
}
/** `<actor>:<organization>:<bucket>`: a key that cannot be shared between tenants. */
export const limitKey = (actorId: string, orgKey: string, bucket: string): string => `${actorId}:${orgKey}:${bucket}`
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// `/api/ready` (spec §13): whether this process can serve a request right now.
//
// The store, the reader and the migration level are required, because a request cannot be answered
// without them: the runtime role answers a query, the global reader answers a query, and every
// migration this build expects (config's `expectedMigrations`, the list the migrate job applies) has
// been recorded. The platform API is reported and not required: a readiness probe that fails when
// an upstream is down takes a web container that is serving perfectly well out of rotation, and this
// app's pages render without it (decision 2026-10-19, AT2.1).
//
// The answer says which check failed and nothing else. The reason -- a connection error, the names
// of the migrations still to run -- goes to the process's log, where an operator reads it, and never
// to whoever asked: the probe is on the public port.
import { missingMigrations } from "@app/config"
export interface Readiness {
readonly ready: boolean
readonly checks: {
readonly store: boolean
readonly reader: boolean
readonly migrations: boolean
readonly platform: boolean
}
}
/** What the probe needs to ask, as functions, so the verdict is testable without a database. */
export interface ReadinessProbes {
/** Resolves when the runtime role answers a query. */
store(): Promise<unknown>
/** Resolves when the global reader answers a query. */
reader(): Promise<unknown>
/** The migration names the database has recorded. */
applied(): Promise<ReadonlySet<string>>
/** The migration names this build needs. */
expected(): Promise<readonly string[]>
/** Whether the platform API answers. */
platform(): Promise<boolean>
/** Where the reason of a failed check goes. */
warn(message: string): void
}
const attempt = async (name: string, probes: ReadinessProbes, run: () => Promise<unknown>): Promise<boolean> => {
try {
await run()
return true
} catch (error) {
// The first line of the message, never the connection string it came from.
probes.warn(`ready: ${name} failed: ${error instanceof Error ? (error.message.split("\n")[0] ?? "") : "failed"}`)
return false
}
}
export async function checkReadiness(probes: ReadinessProbes): Promise<Readiness> {
const [store, reader, migrations, platform] = await Promise.all([
attempt("store", probes, () => probes.store()),
attempt("reader", probes, () => probes.reader()),
attempt("migrations", probes, async () => {
const missing = missingMigrations(await probes.expected(), await probes.applied())
if (missing.length > 0) throw new Error(`the migrate job has not applied ${missing.join(", ")}`)
}),
probes.platform().catch(() => false),
])
return { ready: store && reader && migrations, checks: { store, reader, migrations, platform } }
}
+53
View File
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// An error Next.js caught while rendering or serving a route, sent to observability with the ids of
// the request that caused it (spec §9.5).
//
// The request arrives as the framework describes it -- path, method and headers -- so the edge's
// `traceparent` and `X-Request-ID` are in it, and `withRequest` makes them this error's ids: the
// error joins its page's trace and its request log. A 404 the app raised on purpose
// (`notFound()`) and a redirect are control flow, not errors, and are not reported.
import type { ServerObservability } from "@app/config"
import { withRequest } from "@conusai-sdk/observe-node"
export interface FailedRequest {
readonly path: string
readonly method: string
readonly headers: Readonly<Record<string, string | string[] | undefined>>
}
export interface FailureContext {
readonly routerKind: string
readonly routePath: string
readonly routeType: string
}
/** Next.js marks `notFound()`, `redirect()` and the like with a digest; none of them is a failure. */
const isControlFlow = (error: unknown): boolean => {
const digest = typeof error === "object" && error !== null && "digest" in error ? String(error.digest) : ""
return digest.startsWith("NEXT_") || digest.includes("NEXT_HTTP_ERROR_FALLBACK") || digest.includes("NEXT_REDIRECT")
}
export function reportRequestError(
observe: Pick<ServerObservability, "captureException" | "log">,
error: unknown,
request: FailedRequest,
context: FailureContext,
): void {
if (isControlFlow(error)) return
const headers = new Headers()
for (const [name, value] of Object.entries(request.headers)) {
if (typeof value === "string") headers.set(name, value)
else if (value) headers.set(name, value.join(", "))
}
withRequest(headers, () => {
observe.captureException(error, { route: context.routePath, kind: context.routeType })
observe.log.error("render failed", {
"http.request.method": request.method,
"url.path": request.path.split("?")[0] ?? "",
"conusai.route": context.routePath,
"conusai.route_type": context.routeType,
error: error instanceof Error ? error.name : "unknown",
})
})
}
+138
View File
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What one request gets: its principal, its container and its caller (spec §6.2).
//
// The caller is the only way a procedure runs — from a route handler, from a server component, or
// from another procedure — so input validation, scope resolution, the permission check and the
// kind rule happen once, in the kernel, for every transport at once.
import { ACCOUNT_CREDENTIAL, type AccountCredential } from "@app/modules/account"
import {
type AuthCheckOptions,
authenticateRequest,
isInteractiveSession,
type Principal,
pinToOrganization,
sessionRefKey,
} from "@conusai-kit/auth"
import { type Actor, type Container, createCaller, type TransportCaller } from "@conusai-kit/core"
import { DATA_SOURCE, SYSTEM_DATA } from "@conusai-kit/data"
import { createTenancyHooks, REQUEST_SESSION, TENANCY } from "@conusai-kit/tenancy"
import { appSession, authRoutes, rolesChecked, webRuntime } from "./app"
import { createConvexCaller } from "./convex-caller"
export interface RequestContext {
readonly principal: Principal | null
readonly container: Container
readonly caller: TransportCaller
readonly correlationId: string
/** Who the browser session is, when it is one: an API credential has no page to render. */
readonly user?: { readonly email: string; readonly displayName: string; readonly locale: string }
}
/**
* The actor of a request: the one @conusai-kit/auth resolved from the session cookie, a bearer
* token or an organization API key. Never a header and never anything else the caller sent, and
* never a system actor -- the web shell may not import `@conusai-kit/core/worker` at all, so this
* process cannot make one (the boundary gate).
*/
const actorOf = (principal: Principal | null): Actor | undefined => principal?.actor
export async function requestContext(
request: Request,
requestId?: string,
authCheck: AuthCheckOptions = {},
): Promise<RequestContext> {
const current = webRuntime()
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()
// What the account module asks of the credential: whether a person signed in to this app is behind
// it, and the kit session row it is bound to (a deletion needs a recent sign-in of that session) --
// or, for an external provider's session, which has no kit row, the sign-in time it reported.
const credential: AccountCredential =
principal && isInteractiveSession(principal)
? {
interactive: true,
...(principal.sessionId ? { sessionId: principal.sessionId } : {}),
...(!principal.sessionId && session?.signedInAt !== undefined
? { signedInAt: new Date(session.signedInAt).toISOString() }
: {}),
}
: { interactive: false }
if (current.convex) {
return {
principal,
container: current.root.child(),
caller: createConvexCaller({
...current.convex,
principal,
objectStore: current.platform.objectStore,
credential,
// 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 } } : {}),
}),
correlationId,
...(session
? {
user: {
email: session.profile.email,
displayName: session.profile.displayName,
locale: session.profile.locale,
},
}
: {}),
}
}
const { registry, root } = current
const data = current.data
if (!data) throw new Error("The selected app profile has no request data source")
const container = root.child()
container.value(DATA_SOURCE, data)
// kit-tenancy's `tenancy.me.get` and kit-auth's API-key lookup are the only readers of this
// port, and what it can reach is the three tables the migrations granted the global reader.
container.value(SYSTEM_DATA, data)
const tenancy = createTenancyHooks({ data, registry })
container.value(TENANCY, tenancy)
container.value(ACCOUNT_CREDENTIAL, credential)
// The device session a derived service session is minted for, so that device's sign-out ends it.
container.value(REQUEST_SESSION, session ? sessionRefKey(session.sessionRef) : null)
// An organization API key reaches that one organization, whatever its user is a member of. The
// pin travels in the call context, so the kernel enforces it for every procedure: tenant
// resolution and authorization stay inside the key's organization, and a `user`-scoped
// procedure is refused with the same NotFound a procedure that does not exist gets (AT1.15).
const actor = actorOf(principal)
const context = principal ? pinToOrganization(principal) : actor ? { actor } : {}
const caller = createCaller({ registry, container, tenancy }, { correlationId, ...context })
return {
principal,
container,
caller,
correlationId,
...(session
? {
user: {
email: session.profile.email,
displayName: session.profile.displayName,
locale: session.profile.locale,
},
}
: {}),
}
}
/** The caller for a server component: the same path a route handler takes. */
export async function callerFor(request: Request): Promise<TransportCaller> {
return (await requestContext(request)).caller
}
+298
View File
@@ -0,0 +1,298 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The one HTTP mount (decision AT-D1, spec §6.2). A framework-free kernel router on Hono: every
// procedure is reached by name through the kernel's own caller, which is what validates the
// input, resolves the scope, checks the permission, enforces the procedure kind and validates the
// output. There is no second description of a procedure here -- the registry is the router.
//
// Decision (2026-10-19, AT2.1): the typed client and the OpenAPI document that AT-D1 names oRPC
// for are the kernel's own `caller.call(contract, input)` and openapi.ts, which both read the
// manifests. Building an oRPC router on top would mean declaring every procedure a second time,
// against the one-canonical-copy rule of spec §1; if oRPC's transport is wanted later, it
// replaces this file alone.
//
// Every request runs inside `withRequest`, so an error captured anywhere below it, a log line and a
// span carry the ids the edge attached (traceparent, X-Request-ID) -- and a request that arrives
// without an X-Request-ID is given one, which is also sent back to the caller (spec §9.5).
import { appBrand, appRoot, expectedMigrations, PROFILE, probePlatform, vectorStoreMigrationName } from "@app/config"
import { isInteractiveSession } from "@conusai-kit/auth" // kit:support
import { AppError, toProblem } from "@conusai-kit/core"
import { createSupportProxy } from "@conusai-kit/support/proxy" // kit:support
import { withRequest } from "@conusai-sdk/observe-node"
import { Hono } from "hono"
import { convexCapabilityStatus, webRuntime } from "./app"
import { auditTarget, orgSlugOf } from "./audit"
import { requireCsrf } from "./csrf"
import { createMcpHandler } from "./mcp"
import { openapiDocument } from "./openapi"
import { bucketFor, limitKey, take } from "./rate-limit"
import { checkReadiness } from "./readiness"
import { requestContext } from "./request"
import { streamInput } from "./stream-input"
/**
* What every JSON answer carries, whether or not the proxy ran for the request (it does not for a
* streamed upload, proxy.ts): nothing here is a page, so nothing may be sniffed into one or cached.
*/
const JSON_HEADERS = { "x-content-type-options": "nosniff", "cache-control": "no-store" } as const
const problem = (error: unknown): Response => {
const body = toProblem(error)
const headers: Record<string, string> = { "content-type": "application/problem+json", ...JSON_HEADERS }
if (body.retryAfter !== undefined) headers["retry-after"] = String(body.retryAfter)
return new Response(JSON.stringify(body), { status: body.status, headers })
}
const json = (value: unknown, status = 200): Response =>
new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json", ...JSON_HEADERS } })
/** The slugs a call names, as span attributes: tenant identifiers, never a person's data. */
function scopeAttributes(input: unknown): Record<string, string> {
if (typeof input !== "object" || input === null) return {}
const named = input as Record<string, unknown>
const out: Record<string, string> = {}
for (const key of ["org", "workspace", "project"] as const) {
const value = named[key]
if (typeof value === "string") out[`conusai.${key}_slug`] = value
}
return out
}
const notFoundProcedure = (name: string): AppError =>
new AppError({
type: "/problems/not_found",
title: "Not found",
status: 404,
code: "not_found",
detail: `no procedure ${name}`,
})
/**
* Answers one procedure call: the kernel's caller validates the input, resolves the scope, checks
* the permission and the kind, and validates the output; this adds what only a transport knows --
* the credential, CSRF, the idempotency key, the rate limit, and the audit event and usage meter
* the procedure declared. `POST` carries a JSON input. `PUT` carries the input of a procedure
* whose payload is a byte stream (kit-core `.stream(field)`): the body is handed over unread.
*/
async function serve(
request: Request,
name: string,
method: "GET" | "POST" | "PUT",
requestId: string,
): Promise<Response> {
const { registry, platform, observe } = webRuntime()
const route = registry.router[name]
// The method has to be the one the procedure declares: a streamed procedure is not reachable
// with a JSON body and a JSON one is not reachable with a stream, so neither can be confused for
// the other by a client or a proxy that logs or limits one of them.
if (!route || (method === "PUT") !== (route.stream !== undefined) || (method === "GET") !== route.download)
return problem(notFoundProcedure(name))
const startedAt = Date.now()
try {
const { caller, principal } = await requestContext(request, requestId, { highRisk: route.kind !== "read" })
requireCsrf(request, principal)
// A write that declares itself idempotent is called with a key, or not at all: a retry
// without one is a second write, and the caller cannot tell the difference afterwards.
const idempotencyKey = request.headers.get("idempotency-key")
if (route.idempotent && !idempotencyKey) {
return problem(
new AppError({
type: "/problems/bad_request",
title: "Bad request",
status: 400,
code: "bad_request",
detail: `${name} is idempotent: send an Idempotency-Key header`,
}),
)
}
const input =
method === "GET"
? Object.fromEntries(new URL(request.url).searchParams)
: method === "PUT"
? streamInput(request, route.stream as string)
: request.headers.get("content-length") === "0"
? undefined
: await request.json().catch(() => undefined)
// The organization the call names (its slug is the input, per kit-tenancy) is part of the
// bucket key: an organization-pinned credential carries it on the principal, and a browser
// session — whose principal has no organization — gets it from the validated input, so one
// tenant's burst does not spend the allowance the same person has in another.
//
// An anonymous caller has no verified identity to key on, and the client address is not one:
// X-Forwarded-For is a header any caller that reaches this process can rotate at will (the
// deployment contract does not say which proxy hops to trust), so keying on it would limit
// nothing. Anonymous calls share one bucket per organization instead — the honest limit.
const actorId = principal?.actor.userId ?? "anonymous"
take(
limitKey(actorId, orgSlugOf(input, undefined) ?? principal?.orgId ?? "none", route.rateLimit ?? route.kind),
bucketFor(route),
)
// One span per procedure, under the edge's trace when there is one.
const output = await observe.span(
`procedure ${name}`,
{ "conusai.procedure": name, "conusai.kind": route.kind, ...scopeAttributes(input) },
() => caller.callByName(name, input, idempotencyKey ? { idempotencyKey } : undefined),
)
// The audit event and the usage meter the procedure declared, recorded once here rather
// than in every handler: the transport is the one place that knows the call succeeded, and
// one place is what keeps the viewer's filter and the writes in step (platform.ts).
const orgSlug = orgSlugOf(input, output)
if (route.audit && principal && orgSlug) {
await platform.audit
.log(route.audit, {
actor: { id: principal.actor.userId, kind: "human" },
target: auditTarget(orgSlug),
outcome: "success",
})
.catch((error: unknown) => {
// The call succeeded and the event did not reach the platform: say so, loudly enough to be
// alerted on, without failing a write that is done.
observe.log.error(
`audit: ${route.audit} was not recorded: ${error instanceof Error ? error.message : "failed"}`,
{ "conusai.procedure": name },
)
})
}
if (route.usage && orgSlug) {
platform.usage.track({ customer: { externalId: orgSlug }, meter: route.usage, quantity: 1 })
}
// One line per call, with the request's trace id: the thread an operator follows from a page's
// error to the procedure behind it. No input, no output -- those belong to a tenant.
observe.log.info("procedure", {
"conusai.procedure": name,
"http.response.status_code": 200,
duration_ms: Date.now() - startedAt,
})
if (route.download) {
if (!(output instanceof Response))
throw new AppError({
type: "/problems/internal",
title: "Internal error",
status: 500,
code: "internal",
detail: "the download procedure did not return a response",
})
return output
}
return json(output ?? null)
} catch (error) {
// A body that was not read to its end is cancelled, not left for the connection to drain: a
// refused upload must not keep a client sending the rest of a 100 MB file.
if (method === "PUT") await request.body?.cancel().catch(() => undefined)
observe.log.info("procedure", {
"conusai.procedure": name,
"http.response.status_code": toProblem(error).status,
duration_ms: Date.now() - startedAt,
})
// A refusal the caller caused (4xx) is the answer, not an incident; a 5xx is ours.
if (!(error instanceof AppError) || error.status >= 500) {
observe.captureException(error, { procedure: name })
observe.log.error(`procedure ${name} failed`, {
"conusai.procedure": name,
error: error instanceof Error ? error.name : "unknown",
})
}
return problem(error)
}
}
// 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 (platform.mode === "fake") return Promise.resolve(problem(notFoundProcedure("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)
take(limitKey(principal.actor.userId, "support", "support"), bucketFor({ kind: "effect", rateLimit: "support" }))
},
refuse: problem,
})
return supportProxy(request)
}
// kit:support:end
export const api = new Hono<{ Variables: { requestId: string } }>()
.use("*", async (c, next) => {
const requestId = c.req.header("x-request-id") ?? globalThis.crypto.randomUUID()
const headers = new Headers(c.req.raw.headers)
headers.set("x-request-id", requestId)
c.set("requestId", requestId)
await withRequest(headers, () => next())
c.res.headers.set("x-request-id", requestId)
})
// Liveness: this process is running. It opens no connection, so a database outage does not
// make the platform restart a container that is doing its job.
.get("/api/health", (c) => c.json({ status: "ok", release: process.env.CONUSAI_RELEASE ?? "dev" }))
// Readiness: the store, the reader and the migration level are there, and the platform API is
// reported (readiness.ts says why it is reported rather than required, and why the answer carries
// no reason).
.get("/api/ready", async () => {
const { pools, env, registry, observe } = webRuntime()
if (PROFILE === "convex") {
const capabilities = await convexCapabilityStatus()
const platform = await probePlatform(env.deployment)
const report = {
ready: capabilities.ready,
checks: { ...capabilities, platform },
}
return json(report, report.ready ? 200 : 503)
}
if (!pools)
return json({ ready: false, checks: { store: false, reader: false, migrations: false, platform: false } }, 503)
const report = await checkReadiness({
store: () => pools.runtime.query("select 1"),
reader: () => pools.reader.query("select 1"),
applied: async () => {
const { rows } = await pools.runtime.query<{ name: string }>('select "name" from "kit_migrations"')
return new Set(rows.map((row) => row.name))
},
expected: () =>
expectedMigrations(
registry,
appRoot(),
vectorStoreMigrationName() === null ? [] : [vectorStoreMigrationName() as string],
),
platform: () => probePlatform(env.deployment),
warn: (message) => observe.log.warn(message),
})
return json(report, report.ready ? 200 : 503)
})
.get("/api/openapi.json", (c) => {
const { registry } = webRuntime()
return c.json(openapiDocument(registry, { title: `${appBrand.name} API`, version: registry.kit }))
})
.all("/api/mcp", async (c) => {
const { registry, observe } = webRuntime()
const handler = createMcpHandler({
registry,
observe,
contextFor: (request, requestId) => requestContext(request, requestId),
})
return handler(c.req.raw, c.get("requestId"))
})
.all("/api/support/v1/support/*", (c) => serveSupport(c.req.raw, c.get("requestId"))) // kit:support
.get("/api/v1/:procedure", (c) => serve(c.req.raw, c.req.param("procedure"), "GET", c.get("requestId")))
.post("/api/v1/:procedure", (c) => serve(c.req.raw, c.req.param("procedure"), "POST", c.get("requestId")))
.put("/api/v1/:procedure", (c) => serve(c.req.raw, c.req.param("procedure"), "PUT", c.get("requestId")))
export const handle = async (request: Request): Promise<Response> => api.fetch(request)
+39
View File
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The input of a procedure whose payload is a byte stream (kit-core's `.stream(field)`).
//
// A file of 10 to 100 MB cannot travel as a JSON document, so such a procedure is answered on
// `PUT /api/v1/<procedure>`: the input's other fields are the query string and the request body is
// the one field the contract names, handed over unread. Nothing here buffers it; the procedure's
// handler holds the stream to the size and the type it declared as the bytes arrive.
//
// Every query value is a string, so the procedure's schema is what makes `part` a number
// (`z.coerce`); a key the query repeats is refused rather than guessed at.
import { AppError } from "@conusai-kit/core"
const badRequest = (detail: string): AppError =>
new AppError({ type: "/problems/bad_request", title: "Bad request", status: 400, code: "bad_request", detail })
/** The query string as an object of strings, refusing a key given twice. */
export function queryInput(url: URL): Record<string, string> {
const input: Record<string, string> = {}
for (const [key, value] of url.searchParams) {
if (Object.hasOwn(input, key)) throw badRequest(`the query gives ${key} twice`)
input[key] = value
}
return input
}
/**
* The procedure's input from a `PUT`: the query, plus the request body as the contract's stream
* field. A caller that names the field in the query is refused: the body is the only source, so a
* value cannot arrive twice and be read differently by two layers.
*/
export function streamInput(request: Request, field: string): Record<string, unknown> {
const url = new URL(request.url)
const input: Record<string, unknown> = queryInput(url)
if (Object.hasOwn(input, field)) throw badRequest(`${field} is the request body, not a query parameter`)
// An empty body is an empty stream, not a missing field: the handler's size check then says so.
input[field] = request.body ?? new Uint8Array(0)
return input
}
+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>
)
}
+107
View File
@@ -0,0 +1,107 @@
// 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 { type FormEvent, type ReactElement, useState } 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"
}
export function BetterAuthForm({ mode, returnTo, minPasswordLength, labels }: BetterAuthFormProps): ReactElement {
const [error, setError] = useState<BetterAuthFormError | null>(null)
const [busy, setBusy] = useState(false)
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const form = new FormData(event.currentTarget)
const body: Record<string, string> = {
email: String(form.get("email") ?? ""),
password: String(form.get("password") ?? ""),
}
if (mode === "sign-up") body.name = String(form.get("name") ?? "")
setBusy(true)
setError(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) {
// A full navigation: the next page is rendered on the server with the new session.
window.location.assign(returnTo)
return
}
const answer = (await response.json().catch(() => ({}))) as { code?: string }
setError(errorOf(mode, response.status, answer.code))
} catch {
setError("unavailable")
} finally {
setBusy(false)
}
}
return (
<form method="post" onSubmit={onSubmit} aria-busy={busy}>
{error ? (
<p className="app-error" role="alert">
{labels.errors[error]}
</p>
) : null}
{mode === "sign-up" ? (
<label className="kit-field" htmlFor="better-auth-name">
{labels.name}
<input id="better-auth-name" name="name" type="text" required autoComplete="name" />
</label>
) : null}
<label className="kit-field" htmlFor="better-auth-email">
{labels.email}
<input id="better-auth-email" name="email" type="email" required autoComplete="email" />
</label>
<label className="kit-field" htmlFor="better-auth-password">
{labels.password}
<input
id="better-auth-password"
name="password"
type="password"
required
minLength={minPasswordLength}
autoComplete={mode === "sign-up" ? "new-password" : "current-password"}
aria-describedby={mode === "sign-up" ? "better-auth-password-help" : undefined}
/>
</label>
{mode === "sign-up" ? <p id="better-auth-password-help">{labels.passwordHelp}</p> : null}
<button type="submit" disabled={busy}>
{labels.submit}
</button>
</form>
)
}
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The error boundary's side of the page states. An error boundary is a client component, so it
// cannot ask the server for the person's language; the root layout renders `BoundaryMessages` with
// the strings already in it, and the boundary below the layout reads them back. The one boundary
// above the layout, `global-error.tsx`, has no provider and carries its own two languages.
import { createContext, type ReactElement, type ReactNode, useContext, useEffect } from "react"
import { reportClientError } from "../observe/browser"
import { StateView } from "./state-view"
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>{" "}
<a href="/">{strings.home}</a>
</>
}
>
<p role="alert">{strings.body}</p>
{error.digest ? <p>{strings.code.replace("{digest}", error.digest)}</p> : null}
</StateView>
)
}
+28
View File
@@ -0,0 +1,28 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// Where a page is in organization > workspace > project (spec §3.1). The last entry is the page
// itself and is not a link.
import type { ReactElement } from "react"
export interface Crumb {
readonly text: string
readonly href?: string
}
export function Breadcrumb({ label, crumbs }: { label: string; crumbs: readonly Crumb[] }): ReactElement {
return (
<nav aria-label={label} className="app-breadcrumb">
<ol>
{crumbs.map((crumb, index) => (
<li key={crumb.href ?? crumb.text}>
{crumb.href && index < crumbs.length - 1 ? (
<a href={crumb.href}>{crumb.text}</a>
) : (
<span aria-current={index === crumbs.length - 1 ? "page" : undefined}>{crumb.text}</span>
)}
</li>
))}
</ol>
</nav>
)
}
+31
View File
@@ -0,0 +1,31 @@
// 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.
import {
type ClientLifecycleEvent,
type ClientModule,
composeClientModules,
type LifecycleReport,
} from "@conusai-kit/core"
import { guidesClientModule } from "@conusai-kit/guides/client" // kit:guides
import { supportClientModule } from "@conusai-kit/support/client" // kit:support
const enabled: ClientModule[] = []
enabled.push(guidesClientModule) // kit:guides
enabled.push(supportClientModule) // kit:support
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)
}
+21
View File
@@ -0,0 +1,21 @@
// 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 beside 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. A module's provider reaches the API through the
// shell's transport (`useTransport`), on this origin, like every other client component.
import { type ClientHostContext, ClientModuleHost } from "@conusai-kit/ui"
import type { ReactElement } from "react"
import { composedClientModules } from "./client-module-list"
import { AppTransport } from "./transport"
export function ClientModules({ context }: { context: ClientHostContext }): ReactElement {
return (
<AppTransport>
<ClientModuleHost client={composedClientModules} context={context} />
</AppTransport>
)
}
+164
View File
@@ -0,0 +1,164 @@
// 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)`).
//
// `useRead`/`useWrite` from `@conusai-kit/ui/data` replace this file when they land; the shape of
// the call -- a contract, an input, problem+json out -- does not change when they do.
import { keepaliveBeacon } from "@conusai-kit/ui"
export interface Problem {
title: string
status: number
code: string
detail?: string
errors?: { path: string; message: string }[]
}
export class ProcedureError extends Error {
readonly problem: Problem
constructor(problem: Problem) {
super(problem.detail ?? problem.title)
this.name = "ProcedureError"
this.problem = problem
}
}
const csrf = (): string => document.querySelector('meta[name="csrf-token"]')?.getAttribute("content") ?? ""
/**
* 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): ProcedureError {
const problem = (body ?? {}) as Partial<Problem>
return new ProcedureError({
title: problem.title ?? "Request failed",
status,
code: problem.code ?? "unknown",
...(problem.detail ? { detail: problem.detail } : {}),
...(problem.errors ? { errors: problem.errors } : {}),
})
}
/**
* 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 `ProcedureError`) 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)
})
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The HTML document every page is rendered into: the brand layer on <html>, the header with the
// app's name and logo, and nothing else. 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 layout only supplies the request's
// facts (locale, session, CSRF token).
//
// The `--brand-*` declarations are an attribute of <html> and not a stylesheet, so the very first
// byte of the page already carries them: a branded app never paints the ConusAI palette first.
import { type AppBrand, brandTokenStyle } from "@app/config"
import type { Metadata } from "next"
import type { CSSProperties, ReactElement, ReactNode } from "react"
import { type BrowserObserveSettings, observeMeta } from "../observe/browser"
import type { Theme } from "./preference-cookies"
/** The app's own share image (apps/web/app/api/brand/og): public, because a link preview is fetched without a session. */
export const APP_SHARE_IMAGE = "/api/brand/og"
/**
* What a page says about itself to a browser tab, a share card and a search result: the brand's own name, and
* nothing else. `image` is the share image of a page; an organization's pages pass its own.
*/
export function brandMetadata(brand: AppBrand, image: string = APP_SHARE_IMAGE): Metadata {
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] },
}
}
export interface DocumentProps {
readonly brand: AppBrand
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
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 children: ReactNode
}
export function Document({
brand,
locale,
theme,
signedIn,
csrf,
observe,
signIn,
signOut,
controls,
children,
}: DocumentProps): ReactElement {
return (
<html
lang={locale}
className={theme === "light" ? "light" : undefined}
style={brandTokenStyle(brand) as CSSProperties}
data-brand={brand.shortName}
>
<head>
{csrf ? <meta name="csrf-token" content={csrf} /> : null}
{observe ? <meta {...observeMeta(observe)} /> : null}
</head>
<body>
<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}
</body>
</html>
)
}
+88
View File
@@ -0,0 +1,88 @@
// 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. The answer 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 { type ReactElement, useState } from "react"
import { call, ProcedureError } from "./client"
export interface InviteAcceptLabels {
readonly accept: string
readonly accepted: string
readonly open: string
readonly invalid: string
readonly used: 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" | "expired" | "member" | "failed"> {
switch (code) {
case "not_found":
return "invalid"
case "invitation_used":
return "used"
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 {
await call("tenancy.invitation.accept", { org, token })
setJoined(true)
} catch (error) {
setProblem(labels[inviteErrorKey(error instanceof ProcedureError ? error.problem.code : "")])
} finally {
setBusy(false)
}
}
if (joined) {
return (
<div role="status">
<p>{labels.accepted}</p>
<a href={`/${encodeURIComponent(org)}`}>{labels.open}</a>
</div>
)
}
return (
<>
<button type="button" disabled={busy} onClick={accept}>
{labels.accept}
</button>
{problem ? (
<p className="app-error" role="alert">
{problem}
</p>
) : null}
</>
)
}
+97
View File
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// Invites one person to the organization and hands the administrator the link (spec §3.1). The app
// sends no e-mail of its own: the token exists in the answer of this one call and nowhere else
// (kit-tenancy stores only its hash), so the link is shown here, once, for the administrator to send.
// The link works once, and only for the address it was made for.
import { type FormEvent, type ReactElement, useState } from "react"
import { call, ProcedureError } from "./client"
export interface InviteLabels {
readonly email: string
readonly role: string
readonly submit: string
/** `{email}` is replaced with the address that was invited. */
readonly created: string
readonly link: string
/** The app's `app.failed` message, with `{detail}` still in it. */
readonly failed: string
}
export function InviteMember({
org,
roles,
labels,
}: {
org: string
roles: { value: string; label: string }[]
labels: InviteLabels
}): ReactElement {
const [busy, setBusy] = useState(false)
const [problem, setProblem] = useState<string | undefined>(undefined)
const [created, setCreated] = useState<{ email: string; link: string } | undefined>(undefined)
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
const form = event.currentTarget
const data = new FormData(form)
const email = String(data.get("email") ?? "")
setBusy(true)
setProblem(undefined)
setCreated(undefined)
try {
const invitation = await call<{ token: string }>("tenancy.member.invite", {
org,
email,
role: String(data.get("role") ?? ""),
})
setCreated({ email, link: `${window.location.origin}/invitations/${org}/${invitation.token}` })
form.reset()
} catch (error) {
const detail = error instanceof ProcedureError ? (error.problem.detail ?? error.problem.title) : "unknown"
setProblem(labels.failed.replace("{detail}", detail))
} finally {
setBusy(false)
}
}
return (
<>
<form onSubmit={submit}>
<label className="kit-field" htmlFor="invite-email">
{labels.email}
<input id="invite-email" name="email" type="email" required autoComplete="off" />
</label>
<label className="kit-field" htmlFor="invite-role">
{labels.role}
<select id="invite-role" name="role" required>
{roles.map((role) => (
<option key={role.value} value={role.value}>
{role.label}
</option>
))}
</select>
</label>
<button type="submit" disabled={busy}>
{labels.submit}
</button>
</form>
{problem ? (
<p className="app-error" role="alert">
{problem}
</p>
) : null}
{created ? (
<div role="status">
<p>{labels.created.replace("{email}", created.email)}</p>
<label className="kit-field" htmlFor="invite-link">
{labels.link}
<input id="invite-link" className="app-link-field" readOnly value={created.link} />
</label>
</div>
) : null}
</>
)
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What shows while a page that has real work to do is being fetched. It must not wait for anything itself, so it asks the
// request only for the cookie and the Accept-Language header -- not for the session, which is what
// the page behind it is waiting for.
import { appBrand } from "@app/config"
import { createI18n } from "@conusai-kit/i18n"
import { headers } from "next/headers"
import type { ReactElement } from "react"
import { localeOf } from "../server/preferences"
import { appCatalogues } from "./messages"
import { LoadingView } from "./state-view"
export async function LoadingPage(): Promise<ReactElement> {
const incoming = await headers()
const locale = localeOf({
appLocale: appBrand.defaultLocale,
cookieHeader: incoming.get("cookie"),
acceptLanguage: incoming.get("accept-language"),
})
return <LoadingView label={createI18n(locale, appCatalogues).message("boundary.loading")} />
}
// Where it is mounted, and where it is not, is deliberate. A `loading.tsx` starts the response
// stream, and the stream fixes the status code: a `notFound()` or a `forbidden()` raised below it is
// still answered 200. The checks that must be a true 404, 403 or redirect -- is this person signed in,
// is this organization theirs, does this workspace and this project exist, may they read the audit
// log -- are made in layouts, which sit above the boundary. The loading state is on the two pages
// whose checks are all up there and whose work is slow: the members and the audit log. The pages whose
// 404 is their own (a document, a note, a path no module answers) have none, so that 404 is a status.
+352
View File
@@ -0,0 +1,352 @@
// 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 = {
app: {
title: "Example App",
signIn: "Sign in",
signOut: "Sign out",
signedOut: "You are signed out.",
signedOutBody: "Close this tab, or sign in again.",
home: "Home",
organizations: "Organizations",
noOrganizations: "You are not a member of an organization yet.",
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",
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",
needsSignIn: "Sign in to continue.",
failed: "That did not work: {detail}",
memberCount: "{count, plural, one {# member} other {# members}}",
open: "Open",
workspace: "Workspace",
project: "Project",
},
roles: {
viewer: "Viewer",
member: "Member",
admin: "Administrator",
owner: "Owner",
},
shell: {
organization: "Organization",
switchOrganization: "Switch organization",
language: "Language",
languageEn: "English",
languageLt: "Lietuvių",
theme: "Theme",
themeDark: "Dark",
themeLight: "Light",
account: "Signed in as {name}",
breadcrumb: "Path",
},
signin: {
title: "Sign in",
intro: "Sign in to continue.",
continue: "Continue to sign in",
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: "Your invitation",
intro: "You were invited to join {org}.",
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}.",
accept: "Accept the invitation",
accepted: "You joined {org}.",
open: "Open {org}",
switchAccount: "Sign in with another account",
error: {
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.",
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.",
},
},
members: {
created:
"Invitation created. Send this link to {email}. It works once, for that address only, and is shown only now.",
link: "Invitation link",
openInvitations: "Open invitations",
noInvitations: "There are no open invitations.",
expires: "Expires",
invitedBy: "Invited by",
changeRole: "Change a role",
},
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",
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}",
work: "Work in this project",
archived: "This project is archived.",
module: "Created by the module {module}",
},
} as const
const lt = {
app: {
title: "Pavyzdinė programa",
signIn: "Prisijungti",
signOut: "Atsijungti",
signedOut: "Jūs atsijungėte.",
signedOutBody: "Užverkite šią kortelę arba prisijunkite iš naujo.",
home: "Pradžia",
organizations: "Organizacijos",
noOrganizations: "Kol kas nesate jokios organizacijos narys.",
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",
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",
needsSignIn: "Norėdami tęsti, prisijunkite.",
failed: "Nepavyko: {detail}",
memberCount: "{count, plural, one {# narys} few {# nariai} other {# narių}}",
open: "Atverti",
workspace: "Darbo erdvė",
project: "Projektas",
},
roles: {
viewer: "Skaitytojas",
member: "Narys",
admin: "Administratorius",
owner: "Savininkas",
},
shell: {
organization: "Organizacija",
switchOrganization: "Pakeisti organizaciją",
language: "Kalba",
languageEn: "English",
languageLt: "Lietuvių",
theme: "Tema",
themeDark: "Tamsi",
themeLight: "Šviesi",
account: "Prisijungęs: {name}",
breadcrumb: "Kelias",
},
signin: {
title: "Prisijungimas",
intro: "Norėdami tęsti, prisijunkite.",
continue: "Tęsti prisijungimą",
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: "Jūsų kvietimas",
intro: "Esate pakviesti prisijungti prie „{org}“.",
signInFirst: "Prisijunkite su el. pašto adresu, kuriuo gavote kvietimą, ir grįžkite į šį puslapį.",
signedInAs: "Esate prisijungę kaip {email}.",
accept: "Priimti kvietimą",
accepted: "Prisijungėte prie „{org}“.",
open: "Atverti „{org}“",
switchAccount: "Prisijungti kita paskyra",
error: {
invalid:
"Šis kvietimas netinka paskyrai, su kuria esate prisijungę. Prisijunkite su adresu, kuriuo jį gavote, arba paprašykite naujo.",
used: "Šis kvietimas jau panaudotas.",
expired: "Šio kvietimo galiojimas baigėsi. Paprašykite organizacijos administratoriaus naujo.",
member: "Jau esate šios organizacijos narys.",
failed: "Kvietimo priimti nepavyko. Bandykite dar kartą.",
},
},
members: {
created:
"Kvietimas sukurtas. Išsiųskite šią nuorodą adresu {email}. Ji veikia vieną kartą, tik šiam adresui, ir rodoma tik dabar.",
link: "Kvietimo nuoroda",
openInvitations: "Atviri kvietimai",
noInvitations: "Atvirų kvietimų nėra.",
expires: "Galioja iki",
invitedBy: "Pakvietė",
changeRole: "Pakeisti rolę",
},
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",
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}“",
work: "Darbas šiame projekte",
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) },
}
+16
View File
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The names and the shape of the two preference cookies (src/server/preferences.ts says what they
// mean). Nothing is imported here, so the browser bundle that writes them carries none of the
// server's code.
export const LOCALE_COOKIE = "app_locale"
export const THEME_COOKIE = "app_theme"
export const THEMES = ["dark", "light"] as const
export type Theme = (typeof THEMES)[number]
/** A year: a preference is not a session. */
export const PREFERENCE_SECONDS = 60 * 60 * 24 * 365
/** The `document.cookie` string a switcher writes for a preference. */
export const preferenceCookie = (name: string, value: string): string =>
`${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${PREFERENCE_SECONDS}; SameSite=Lax`
+99
View File
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// One form for one procedure: the fields are declared by the caller, the submit calls the
// procedure by name, and a problem+json refusal is shown where it happened (spec §6.2). Every
// label is a message the server passed in, so this component holds no string of its own.
import { type FormEvent, type ReactElement, useState } from "react"
import { call, ProcedureError } from "./client"
export interface Field {
name: string
label: string
type?: "text" | "email"
required?: boolean
options?: { value: string; label: string }[]
}
export function ProcedureForm({
procedure,
fields,
submitLabel,
failedTemplate,
hidden = {},
after,
}: {
procedure: string
fields: Field[]
submitLabel: string
/** The app's `app.failed` message, with `{detail}` still in it. */
failedTemplate: string
hidden?: Record<string, string>
/**
* Where to go once the procedure has answered, with `{field}` replaced by what was submitted for
* that field (`/{slug}` after creating an organization). Without it the page reloads, which is
* what a form on a list wants.
*/
after?: string
}): ReactElement {
const [problem, setProblem] = useState<string | undefined>(undefined)
const [busy, setBusy] = useState(false)
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault()
setBusy(true)
setProblem(undefined)
const form = new FormData(event.currentTarget)
const input: Record<string, string> = { ...hidden }
for (const field of fields) {
const value = form.get(field.name)
if (typeof value === "string" && value.length > 0) input[field.name] = value
}
try {
await call(procedure, input)
if (after)
window.location.assign(after.replace(/\{(\w+)\}/g, (_, name: string) => encodeURIComponent(input[name] ?? "")))
else window.location.reload()
} catch (error) {
const detail = error instanceof ProcedureError ? (error.problem.detail ?? error.problem.title) : "unknown"
setProblem(failedTemplate.replace("{detail}", detail))
} finally {
setBusy(false)
}
}
return (
<form onSubmit={submit}>
{fields.map((field) => (
<label className="kit-field" key={field.name} htmlFor={`field-${field.name}`}>
{field.label}
{field.options ? (
<select id={`field-${field.name}`} name={field.name} required={field.required ?? false}>
{field.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : (
<input
id={`field-${field.name}`}
name={field.name}
type={field.type ?? "text"}
required={field.required ?? false}
/>
)}
</label>
))}
<button type="submit" disabled={busy}>
{submitLabel}
</button>
{problem ? (
<p className="app-error" role="alert">
{problem}
</p>
) : null}
</form>
)
}
+42
View File
@@ -0,0 +1,42 @@
// 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).
//
// A module with pages under a project says so in its manifest (`ui.nav`, scope `project`) and
// `modules/index.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
// (`app/[org]/[workspace]/[project]/documents`), so the shell lists it by name.
import { PROFILE } from "@app/config"
import { appModules } from "@app/modules"
import type { Profile } from "@conusai-kit/platform"
export interface ProjectEntry {
/** The path segment under the project. */
readonly href: string
/** The message key of its label. */
readonly label: string
/** The module that brings it: what a project created for that module is keyed by. */
readonly module: string
}
/** Documents, then every project-level entry the manifests declare. */
export function projectEntriesFor(profile: Profile): readonly ProjectEntry[] {
return [
{ href: "documents", label: "documents.title", module: "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 })),
),
]
}
export const PROJECT_ENTRIES: readonly ProjectEntry[] = projectEntriesFor(PROFILE)
/** The values a project's `moduleKey` can take when it is created from the shell. */
export const PROJECT_MODULE_KEYS: readonly string[] = [...new Set(PROJECT_ENTRIES.map((entry) => entry.module))]
export const projectHref = (org: string, workspace: string, project: string, entry?: ProjectEntry): string =>
`/${org}/${workspace}/${project}${entry ? `/${entry.href}` : ""}`
+120
View File
@@ -0,0 +1,120 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The controls of the shell that change how the app looks to the person using it: the theme, the
// language and, for someone in more than one organization, the organization. Every label is a
// message the server passed in, so this file holds no string of its own.
import { type ChangeEvent, type ReactElement, useState } from "react"
import { call, ProcedureError } from "./client"
import { LOCALE_COOKIE, preferenceCookie, THEME_COOKIE, type Theme } from "./preference-cookies"
/**
* The theme, a switch. The server renders the class of the cookie, so the first paint is right; this
* flips it on the one element that carries it (the root, spec §10.3) and remembers the choice.
*/
export function ThemeToggle({
initial,
label,
darkLabel,
lightLabel,
}: {
initial: Theme
label: string
darkLabel: string
lightLabel: string
}): ReactElement {
const [theme, setTheme] = useState<Theme>(initial)
const flip = (): void => {
const next: Theme = theme === "light" ? "dark" : "light"
document.documentElement.classList.toggle("light", next === "light")
// 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(THEME_COOKIE, next)
setTheme(next)
}
return (
<button type="button" className="app-control" aria-pressed={theme === "light"} onClick={flip}>
{label}: {theme === "light" ? lightLabel : darkLabel}
</button>
)
}
/**
* The language. A visitor's choice is a cookie; a signed-in person's is saved on their user row
* (`tenancy.me.update`) so it follows them to the next browser, and the cookie is written as well so
* the page they sign out to is in the same language.
*/
export function LanguageSwitch({
current,
signedIn,
label,
options,
failedTemplate,
}: {
current: string
signedIn: boolean
label: string
options: { value: string; label: string }[]
failedTemplate: string
}): ReactElement {
const [problem, setProblem] = useState<string | undefined>(undefined)
const change = async (event: ChangeEvent<HTMLSelectElement>): Promise<void> => {
const locale = event.currentTarget.value
setProblem(undefined)
try {
if (signedIn) await call("tenancy.me.update", { locale })
// biome-ignore lint/suspicious/noDocumentCookie: as above
document.cookie = preferenceCookie(LOCALE_COOKIE, locale)
window.location.reload()
} catch (error) {
const detail = error instanceof ProcedureError ? (error.problem.detail ?? error.problem.title) : "unknown"
setProblem(failedTemplate.replace("{detail}", detail))
}
}
return (
<label className="app-control">
{label}{" "}
<select value={current} onChange={change}>
{options.map((option) => (
<option key={option.value} value={option.value} lang={option.value}>
{option.label}
</option>
))}
</select>
{problem ? (
<span className="app-error" role="alert">
{problem}
</span>
) : null}
</label>
)
}
/** Moves between the organizations a person belongs to; shown only when there is more than one. */
export function OrganizationSwitch({
current,
label,
organizations,
}: {
current: string
label: string
organizations: { slug: string; name: string }[]
}): ReactElement {
return (
<label className="app-control">
{label}{" "}
<select
value={current}
onChange={(event) => {
window.location.assign(`/${encodeURIComponent(event.currentTarget.value)}`)
}}
>
{organizations.map((organization) => (
<option key={organization.slug} value={organization.slug}>
{organization.name}
</option>
))}
</select>
</label>
)
}
+29
View File
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
import type { FormEvent, ReactElement } from "react"
import { endClientModules } from "./client-module-list"
/** How long the modules' sign-out handlers may hold the sign-out; they clear browser storage only. */
const LIFECYCLE_BUDGET_MS = 1000
export function SignOut({ label }: { label: string }): ReactElement {
// A form, not a link: signing out revokes the session -- a state change, so it is a POST, which
// no prefetch or crawler fires (the route refuses a GET). The provider's end-session redirect
// follows in the response (kit-auth's routes).
//
// The modules' browser state is cleared first (joint contract §12): the navigation that follows
// unmounts nothing, so without this the guides progress and unsent events of the person who signed
// out would stay in this tab's storage. A failing handler does not stop the sign-out.
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
const form = event.currentTarget
const budget = new Promise<void>((resolve) => setTimeout(resolve, LIFECYCLE_BUDGET_MS))
void Promise.race([endClientModules("sign-out").catch(() => undefined), budget]).finally(() => form.submit())
}
return (
<form method="post" action="/auth/sign-out" onSubmit={onSubmit}>
<button type="submit">{label}</button>
</form>
)
}
+36
View File
@@ -0,0 +1,36 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The page a person sees when there is no page to see: not found, not allowed, failed, loading. One
// shape for all of them, so the four read as one family and each is a single heading, a sentence and
// the way out. Every string is passed in by the caller, which has the person's language.
import type { ReactElement, ReactNode } from "react"
export function StateView({
title,
children,
actions,
}: {
title: string
children?: ReactNode
actions?: ReactNode
}): ReactElement {
return (
<main className="app-state">
<h1>{title}</h1>
{children}
{actions ? <p>{actions}</p> : null}
</main>
)
}
/** What a page that is still being fetched shows. `role="status"` announces it once, politely. */
export function LoadingView({ label }: { label: string }): ReactElement {
return (
<main>
<div className="app-loading" role="status">
<span className="app-spinner" aria-hidden="true" />
<span>{label}</span>
</div>
</main>
)
}
+35
View File
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
"use client"
// The browser's transport for the modules' interactive components: this app's own `call`, with the
// session cookie, the CSRF token and problem+json refusals (client.ts). A module cannot import the
// app, and a server component cannot pass a function to a client one, so a page wraps what it
// renders in this provider and each module's components ask for it.
//
// `TransportProvider` of `@conusai-kit/ui` is the one a module written for the kit asks for; the
// two older modules (branding, documents) each carry a provider of their own, and are wrapped here
// until they are moved onto the kit's.
import { TransportProvider as BrandingTransport } from "@app/modules/branding/ui"
import { TransportProvider as DocumentsTransport } from "@app/modules/documents/ui"
import { TransportProvider as KitTransport } from "@conusai-kit/ui"
import type { ReactElement, ReactNode } from "react"
import { authenticatedFetch, beacon, call, ProcedureError, sendBody } from "./client"
/** What a person is told about a refusal: the server's own detail, never the request. */
const describe = (error: unknown): string =>
error instanceof ProcedureError ? (error.problem.detail ?? error.problem.title) : "unknown"
const run = (procedure: string, input: unknown): Promise<unknown> => call(procedure, input)
export function AppTransport({ children }: { children: ReactNode }): ReactElement {
return (
<KitTransport call={run} describe={describe} send={sendBody} fetch={authenticatedFetch} beacon={beacon}>
<DocumentsTransport call={run} describe={describe}>
<BrandingTransport call={run} describe={describe}>
{children}
</BrandingTransport>
</DocumentsTransport>
</KitTransport>
)
}
+17
View File
@@ -0,0 +1,17 @@
{
"//": "The values Next.js 16 mandates are already here, so `next build` does not rewrite this file and dirty the checkout. It extends the plain base, not tsconfig.paths.json: the shell resolves @app/config and @app/modules through node_modules, to the JavaScript those packages emit, which is what Turbopack loads at build time too (`pnpm build:libs` builds them first).",
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"module": "esnext",
"moduleResolution": "bundler",
"jsx": "preserve",
"noEmit": true,
"incremental": true,
"types": ["node"],
"plugins": [{ "name": "next" }]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"],
"exclude": ["node_modules"]
}
+45
View File
@@ -0,0 +1,45 @@
{
"name": "@app/worker",
"version": "0.1.0",
"private": true,
"description": "The background process of this app: module jobs, module cron, the outbox relay, and the migrate job that owns the schema.",
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start": "node dist/main.js",
"seed": "node dist/seed-main.js",
"migrate": "node dist/migrate.js",
"clean": "rm -rf dist"
},
"dependencies": {
"@app/config": "workspace:*",
"@app/modules": "workspace:*",
"@conusai-kit/auth": "catalog:kit",
"@conusai-kit/auth-convex-better-auth": "catalog:kit",
"@conusai-kit/core": "catalog:kit",
"@conusai-kit/data": "catalog:kit",
"@conusai-kit/data-kysely": "catalog:kit",
"@conusai-kit/files": "catalog:kit",
"@conusai-kit/jobs-pgboss": "catalog:kit",
"@conusai-kit/platform": "catalog:kit",
"@conusai-kit/proposals": "catalog:kit",
"@conusai-kit/tenancy": "catalog:kit",
"@conusai-sdk/node-sdk": "catalog:",
"@conusai-sdk/observe-node": "catalog:",
"kysely": "catalog:",
"pg": "catalog:",
"pg-boss": "catalog:",
"zod": "catalog:",
"@conusai-kit/vector-qdrant": "catalog:kit",
"@qdrant/js-client-rest": "catalog:",
"@conusai-kit/agent": "catalog:kit",
"@conusai-kit/guides": "catalog:kit",
"@conusai-kit/support": "catalog:kit"
},
"devDependencies": {
"@types/node": "26.6.5",
"@types/pg": "catalog:",
"typescript": "5.9.3"
}
}
+18
View File
@@ -0,0 +1,18 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The worker's part of the Convex Better Auth adapter (AUTH_PROVIDER=convex-better-auth): an account
// deletion records the provider whose account it ends, and the sweep that resumes one ends the Better
// Auth account at that step, as the web server would (apps/web/src/server/better-auth.ts).
// `kit template:prune --auth oidc` removes this file with the lines that call it.
import { ACCOUNT_IDENTITY_PROVIDER } from "@app/modules/account"
import { betterAuthAccountEndFromEnv } from "@conusai-kit/auth-convex-better-auth/account"
import type { Container } from "@conusai-kit/core"
/**
* Binds the account's end when Better Auth is the app's provider. Read at start-up, so a worker that
* lacks the deployment's variables stops by name instead of holding every deletion at that step.
*/
export function bindBetterAuthAccountEnd(container: Container, env: Record<string, string | undefined>): void {
if (env.AUTH_PROVIDER?.trim() !== "convex-better-auth") return
container.value(ACCOUNT_IDENTITY_PROVIDER, betterAuthAccountEndFromEnv(env))
}
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// kit:guides -- the guides module's build step; `kit template:prune --without guides` removes this file.
//
// Writes the app's code guides and its anchor catalogue into the image as one declarative, public
// file (ADR-045): `node apps/worker/dist/guides-bundle.js <out> [anchors]`, run by the Dockerfile
// after `kit guides:anchors`. conusai.json names <out> under `guides.bundle`, and after a healthy
// deploy the platform reads it out of the image and publishes it, with a credential no container
// ever holds. An invalid code guide or a broken catalogue fails the build.
import { existsSync } from "node:fs"
import { resolve } from "node:path"
import { appModules } from "@app/modules"
import { buildRegistry } from "@conusai-kit/core"
import { writeGuidesBundle } from "@conusai-kit/guides/deploy"
const [out, anchors] = process.argv.slice(2)
if (!out) {
console.error("guides-bundle: usage: node apps/worker/dist/guides-bundle.js <absolute out path> [anchors file]")
process.exit(64)
}
const anchorsFile = anchors && existsSync(anchors) ? resolve(anchors) : undefined
const bundle = writeGuidesBundle(buildRegistry([...appModules]), anchorsFile, out)
console.info(
`guides-bundle: wrote ${bundle.guides.length} code guide(s) and ${bundle.anchors.length} anchor(s) to ${out}`,
)
+24
View File
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The worker's healthcheck (compose.yaml). It reads the heartbeat the process writes rather than
// opening a database connection: a healthcheck that connects to Postgres every fifteen seconds
// reports the database's health, not the worker's, and holds a connection the app could use.
import { readFile } from "node:fs/promises"
const STALE_AFTER_MS = 45_000
async function main(): Promise<void> {
const file = process.env.WORKER_HEARTBEAT_FILE ?? "/tmp/worker-heartbeat"
const written = Number.parseInt(await readFile(file, "utf8"), 10)
if (!Number.isFinite(written)) throw new Error(`${file} holds no timestamp`)
const age = Date.now() - written
if (age > STALE_AFTER_MS) throw new Error(`the last heartbeat is ${Math.round(age / 1000)}s old`)
}
main().then(
() => process.exit(0),
(error: unknown) => {
console.error(`worker healthcheck: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
},
)
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// What the worker tells observability about its work (spec §9.5): one span per job, and one log
// line per worker event, with a failed job reported as an error.
//
// Neither touches a module. The jobs a module declares in its manifest are wrapped before the
// registry is built, so a module's handler runs as the same function inside a span; the worker's own
// log callback turns the kit's events into lines. No payload is ever logged -- a job's payload
// belongs to a tenant (kit-jobs-pgboss) -- and nothing here carries a person's data.
import type { ServerObservability } from "@app/config"
import type { JobContext, ModuleManifest } from "@conusai-kit/core"
import { JOB_SCOPE } from "@conusai-kit/jobs-pgboss"
/** The tenant ids of the job's scope as span attributes: opaque identifiers, no names, no addresses. */
function scopeAttributes(context: JobContext): Record<string, string> {
try {
const scope = context.container.resolve(JOB_SCOPE)
return {
"conusai.org_id": scope.orgId,
...("workspaceId" in scope && typeof scope.workspaceId === "string"
? { "conusai.workspace_id": scope.workspaceId }
: {}),
...("projectId" in scope && typeof scope.projectId === "string" ? { "conusai.project_id": scope.projectId } : {}),
}
} catch {
// A container without a job scope (a test's): the span is still worth having without it.
return {}
}
}
/** The manifests with each module job's handler run inside a span named `job <name>`. */
export function traceJobs(modules: readonly ModuleManifest[], observe: ServerObservability): ModuleManifest[] {
return modules.map((module) => {
if (!module.jobs) return module
const jobs = Object.fromEntries(
Object.entries(module.jobs).map(([name, def]) => [
name,
{
...def,
handler: (payload: unknown, context: JobContext) =>
observe.span(
`job ${name}`,
{
"conusai.job": name,
"conusai.attempt": context.attempt,
"conusai.correlation_id": context.correlationId,
...scopeAttributes(context),
},
() => def.handler(payload, context),
),
},
]),
)
return { ...module, jobs }
})
}
/** The kit's worker events that carry an `error`; the rest are plain facts. */
interface WorkerEventLike {
readonly kind: string
readonly [field: string]: unknown
}
const FAILURES = new Set(["job.failed", "relay.failed", "relay.invalid-payload", "job.rejected", "relay.stuck"])
/** One line per event, and an error report for a failure. Only ids, names and counts are logged. */
export function logWorkerEvent(observe: ServerObservability, event: WorkerEventLike): void {
const { kind, error, ...fields } = event
const attributes: Record<string, string | number | boolean> = { event: kind }
for (const [name, value] of Object.entries(fields)) {
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") attributes[name] = value
}
if (!FAILURES.has(kind)) {
// The relay's pass runs every second or so, delivered or not: a line per pass is noise at `info`.
const line = `worker: ${kind}`
if (kind === "relay.pass") observe.log.debug(line, attributes)
else observe.log.info(line, attributes)
return
}
observe.log.error(`worker: ${kind}`, {
...attributes,
...(error instanceof Error ? { error: error.name } : {}),
})
if (error !== undefined) observe.captureException(error, { event: kind })
}
+80
View File
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The worker process: module jobs, module cron and the outbox relay (spec §8).
//
// Everything here runs as a system actor, which is why this is the only app that may import
// @conusai-kit/core/worker (the boundary gate enforces it). A handler that must obey a rule such
// as "a suspended organization is read-only" calls the module's procedure through the kernel's
// caller rather than writing through a repository, so that rule lives in one place (AT-D19).
import { writeFile } from "node:fs/promises"
import { assertBoundByRowLevelSecurity, loadAppEnv, roleFacts, roleLine, startObservability } from "@app/config"
import { createWorker } from "@conusai-kit/jobs-pgboss"
import { logWorkerEvent } from "./instrument.js"
import { buildWorkerRuntime } from "./runtime.js"
import { appSchedules } from "./schedules.js"
/** The file the healthcheck reads. The relay touches it on every pass, so a stuck relay is visible. */
const heartbeatFile = (): string => process.env.WORKER_HEARTBEAT_FILE ?? "/tmp/worker-heartbeat"
async function main(): Promise<void> {
// Before anything else is built: a missing platform variable or an owner's URL stops the worker
// here, with a typed error that names it, and nothing has connected to anything yet.
const env = loadAppEnv("worker")
const observe = startObservability(env)
const runtime = buildWorkerRuntime(env, observe)
const { pools } = runtime
// Which role this process actually holds, before it serves a queue (decision AT-D20). A worker
// on the owner's login would write past every policy, so it refuses to start instead.
const runtimeRole = assertBoundByRowLevelSecurity(await roleFacts(pools.runtime), "POSTGRES_URL")
const readerRole = assertBoundByRowLevelSecurity(await roleFacts(pools.reader), "POSTGRES_READER_URL")
observe.log.info(roleLine("worker", runtimeRole))
observe.log.info(roleLine("worker reader", readerRole))
const worker = createWorker({
registry: runtime.registry,
boss: runtime.boss,
dataSource: runtime.data,
container: runtime.container,
schedules: appSchedules(),
log: (event) => {
// One line per event, no payload: a job's payload belongs to a tenant (kit-jobs-pgboss).
logWorkerEvent(observe, event)
void writeFile(heartbeatFile(), String(Date.now())).catch(() => undefined)
},
})
await worker.start()
await writeFile(heartbeatFile(), String(Date.now()))
observe.log.info(`worker: serving ${worker.queues.length} queue(s)`)
// The relay's own heartbeat, independent of whether anything was delivered: a worker with no
// work to do is healthy, and must say so.
const beat = setInterval(() => {
void writeFile(heartbeatFile(), String(Date.now())).catch(() => undefined)
}, 5_000)
beat.unref()
let stopping = false
const stop = (signal: string): void => {
if (stopping) return
stopping = true
observe.log.info(`worker: ${signal}, finishing the jobs in flight`)
void (async () => {
clearInterval(beat)
await worker.stop()
await runtime.close()
await observe.shutdown()
process.exit(0)
})()
}
process.on("SIGTERM", () => stop("SIGTERM"))
process.on("SIGINT", () => stop("SIGINT"))
}
main().catch((error: unknown) => {
// Nothing may have started when this runs (a bad environment), so it is written plainly: the
// message names the variable, and a typed error carries no secret by construction.
console.error(`worker: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
})
+103
View File
@@ -0,0 +1,103 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The migrate job (conusai.json: `"migrate": { "kind": "job", "postgres": { "migrate": true } }`).
//
// It is the only process that holds POSTGRES_MIGRATE_URL, the owner's URL, and the only one that
// writes the schema (decision AT-D20). Three things happen here, in this order:
//
// 1. the app's own forward-only migrations, newest last, from migrations/postgres
// 2. every module's declared migration folder, in the registry's dependency order
// 3. the job adapter's schema, and then the grants that let the runtime role use it and the
// global reader read the few tables it must read across tenants
// kit:guides:begin
// 4. with the guides module, the DEPRECATED compose hand-off of the code guides and the anchor
// catalogue. This app declares guides.bundle in conusai.json, so the platform publishes the
// bundle the image build wrote (apps/worker/src/guides-bundle.ts, ADR-045) and hands this job
// no credential: the step logs that it skipped. It stays only until the compose hand-off is
// removed, for an app that still has no guides.bundle.
// kit:guides:end
// kit:convex-better-auth:begin
// 5. with AUTH_PROVIDER=convex-better-auth, the Better Auth functions (better-auth/), deployed to
// the app's Better Auth Convex deployment with its SITE_URL and secret (better-auth/deploy.mjs)
// kit:convex-better-auth:end
//
// It exits non-zero when anything fails, which is what web and worker wait on:
// `depends_on: { migrate: { condition: service_completed_successfully } }` in compose.yaml
// (decision AT-D14 -- `kind: job` alone does not order a deployment).
import { spawnSync } from "node:child_process" // kit:convex-better-auth
import { existsSync } from "node:fs" // kit:guides
import { fileURLToPath } from "node:url" // kit:guides
import { assertOwnsSchema, loadAppEnv, roleFacts, roleLine } from "@app/config"
import { appModules } from "@app/modules"
import { buildRegistry } from "@conusai-kit/core"
import { runMigrations } from "@conusai-kit/data-kysely"
import { describeOutcome, publishGuidesAtDeploy } from "@conusai-kit/guides/deploy" // kit:guides
import { createPgBoss } from "@conusai-kit/jobs-pgboss"
import { Kysely, PostgresDialect } from "kysely"
import { Pool } from "pg"
import { jobSchemaGrants, moduleMigrations, readerGrants, sqlMigrations, vectorStoreMigration } from "./migrations.js"
async function main(): Promise<void> {
const env = loadAppEnv("migrate")
const postgres = env.postgres
if (!postgres) throw new Error("the selected profile has no Postgres migrations")
const owner = postgres.owner
if (!owner) throw new Error("the migrate job has no owner URL, which loadAppEnv refuses before this line")
const registry = buildRegistry([...appModules])
const pool = new Pool({ connectionString: owner, max: 2 })
const db = new Kysely<Record<string, never>>({ dialect: new PostgresDialect({ pool }) })
try {
// The owner, and this is the only process that may be it (decision AT-D20). It refuses to
// migrate as anything else: tables created by another role would be owned by it.
console.info(roleLine("migrate", assertOwnsSchema(await roleFacts(pool), "POSTGRES_MIGRATE_URL")))
const app = await sqlMigrations()
const modules = await moduleMigrations(registry)
const vectors = vectorStoreMigration(process.env)
const first = await runMigrations(db, [...app, ...modules, ...(vectors === null ? [] : [vectors])])
console.info(`migrate: applied ${first.applied.join(", ") || "nothing"}; skipped ${first.skipped.length}`)
// pg-boss owns its schema and creates it with the role in its connection string, which is
// this one. The runtime role is then granted what a transactional enqueue needs, in the same
// step that created the schema -- a grant left for later is a grant nobody runs.
const boss = createPgBoss({ connectionString: owner })
await boss.start()
await boss.stop({ graceful: true })
const grants = await runMigrations(db, [readerGrants(postgres.reader), jobSchemaGrants(postgres.runtime)])
console.info(`migrate: applied ${grants.applied.join(", ") || "nothing"}`)
// kit:guides:begin -- after the schema, so a refused publication never leaves a half-migrated
// database; a refusal is logged and the deployment goes on (the console shows the guide's state).
const anchors = new URL("../../../guides-anchors.json", import.meta.url)
const guides = await publishGuidesAtDeploy({
registry,
env: {
CONUSAI_API_URL: process.env.CONUSAI_API_URL,
CONUSAI_GUIDES_PUBLISH_TOKEN: process.env.CONUSAI_GUIDES_PUBLISH_TOKEN,
CONUSAI_GUIDES_DEPLOYMENT: process.env.CONUSAI_GUIDES_DEPLOYMENT,
},
...(existsSync(anchors) ? { anchorsFile: fileURLToPath(anchors) } : {}),
})
console.info(`migrate: ${describeOutcome(guides)}`)
// kit:guides:end
// kit:convex-better-auth:begin -- a failed deploy fails the job, so web and worker never start
// against a Better Auth deployment that cannot answer their session checks.
if (process.env.AUTH_PROVIDER?.trim() === "convex-better-auth") {
const script = new URL("../../../better-auth/deploy.mjs", import.meta.url).pathname
const deployed = spawnSync(process.execPath, [script], { env: process.env, stdio: "inherit" })
if (deployed.error || deployed.status !== 0) throw new Error("the Better Auth functions were not deployed")
}
// kit:convex-better-auth:end
} finally {
await db.destroy()
}
}
main().then(
() => process.exit(0),
(error: unknown) => {
console.error(`migrate: ${error instanceof Error ? error.message : String(error)}`)
process.exit(1)
},
)
+139
View File
@@ -0,0 +1,139 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The app's forward-only migrations for the Postgres profile (spec §13).
//
// 0001_kernel.sql is committed: it is the schema `schemaStatements()` generated for the kernel's
// tables once, and it stays byte-identical afterwards, because the migration runner refuses a
// migration whose text changed after it ran. A module added later ships its own numbered file;
// `test/migrations.test.ts` fails when an entity of the registry has no table in any of them.
//
// The reader grants cannot be a committed file: they name the global reader's role, which is
// derived from the database and known only from POSTGRES_READER_URL at run time (data-services
// DS21). They are therefore built here, from that one name.
import { readFile } from "node:fs/promises"
import {
appMigrationFiles,
GRANT_MIGRATIONS,
type MigrationFile,
moduleMigrationFiles,
roleOf,
vectorDimensionsOf,
vectorStoreMigrationName,
} from "@app/config"
import { AUTH_CROSS_TENANT_READS } from "@conusai-kit/auth"
import type { Registry } from "@conusai-kit/core"
import { type Migration, readerMigration, schemaStatements, vectorMigration } from "@conusai-kit/data-kysely"
import { TENANCY_CROSS_TENANT_READS } from "@conusai-kit/tenancy"
/** A migration file as the runner takes it: its recorded name and its statements. */
async function read(file: MigrationFile): Promise<Migration> {
return { name: file.name, statements: splitStatements(await readFile(file.path, "utf8")) }
}
/**
* Every committed migration, in file-name order, which is the order they were written in. Which
* files there are is config's (`appMigrationFiles`), because the web shell's readiness probe asks
* the same question of the same folder.
*/
export async function sqlMigrations(): Promise<Migration[]> {
return Promise.all((await appMigrationFiles()).map(read))
}
/**
* Splits a migration file into statements on `;` at the end of a line, which is the only
* separator these files use. A `$$`-quoted body is kept whole.
*/
export function splitStatements(sql: string): string[] {
const withoutComments = sql
.split("\n")
.filter((line) => !line.trimStart().startsWith("--"))
.join("\n")
const statements: string[] = []
let current = ""
let inDollar = false
for (const part of withoutComments.split(/(\$\$)/)) {
if (part === "$$") {
inDollar = !inDollar
current += part
continue
}
if (inDollar) {
current += part
continue
}
const pieces = part.split(";")
for (const [index, piece] of pieces.entries()) {
current += piece
if (index < pieces.length - 1) {
if (current.trim()) statements.push(current.trim())
current = ""
}
}
}
if (current.trim()) statements.push(current.trim())
return statements
}
/**
* Exactly what the global reader may read (decision AT-D20), and nothing else. It is not a list
* kept here: each kit module declares the cross-tenant reads it makes (`crossTenantRead`, kit-data)
* beside the code that makes them, a test in the module fails when the two disagree, and this is the
* union of those declarations:
*
* org_memberships which organizations a user is a member of, the one question that belongs
* to no organization (kit-tenancy, read by tenancy.me.get)
* projects the projects the app serves, which the worker lists to fan a scheduled job
* (`files.recover`) out to each of them (kit-tenancy `servedProjects`)
* auth_api_keys the lookup of an API key by its hash, before any scope exists (kit-auth)
*
* and, added by `readerMigration` itself, the outbox the relay drains across organizations. Its
* `update` is granted on the relay's bookkeeping columns, not on the table: a grant on the whole row
* would let it rewrite an event's organization, name or payload -- which the relay then hands to a
* subscriber running as a system actor.
*
* Each of those tables carries FORCE row-level security on `app.org_id`, and the reader owns
* nothing, so the policy binds it as it binds the runtime role: without a policy of its own the
* reader would see zero rows. The policies `readerMigration` writes are `to` the reader alone, so
* the runtime role keeps exactly the tenant wall it had.
*/
export const CROSS_TENANT_READS = [...TENANCY_CROSS_TENANT_READS, ...AUTH_CROSS_TENANT_READS]
export function readerGrants(readerUrl: string): Migration {
return readerMigration(roleOf(readerUrl), CROSS_TENANT_READS, { name: GRANT_MIGRATIONS[0] })
}
/** What the runtime role needs in pg-boss's schema, once the job adapter has created it. */
export function jobSchemaGrants(runtimeUrl: string, schema = "pgboss"): Migration {
const runtime = `"${roleOf(runtimeUrl)}"`
return {
name: GRANT_MIGRATIONS[1],
statements: [
`grant usage on schema "${schema}" to ${runtime}`,
`grant select, insert, update, delete on all tables in schema "${schema}" to ${runtime}`,
`grant usage, select on all sequences in schema "${schema}" to ${runtime}`,
`alter default privileges in schema "${schema}" grant select, insert, update, delete on tables to ${runtime}`,
`alter default privileges in schema "${schema}" grant usage, select on sequences to ${runtime}`,
],
}
}
/** Each module's SQL files, named `<module>/<file>` so two modules cannot collide. */
export async function moduleMigrations(registry: Registry): Promise<Migration[]> {
return Promise.all((await moduleMigrationFiles(registry)).map(read))
}
/**
* The kernel schema as the committed `0001_kernel.sql` holds it: one statement per paragraph, so
* a diff of the file reads as a diff of the schema (test/kernel-migration.test.ts).
*/
export function kernelSchema(entities: Parameters<typeof schemaStatements>[0]): string {
return `${schemaStatements(entities)
.map((statement) => `${statement};`)
.join("\n\n")}\n`
}
/** The runtime-built migration that creates the pgvector table, when this deployment needs one. */
export function vectorStoreMigration(environment: Record<string, string | undefined> = process.env): Migration | null {
const name = vectorStoreMigrationName(environment)
return name === null ? null : { name, statements: vectorMigration(vectorDimensionsOf(environment)).statements }
}
+221
View File
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// The ports the kit's modules and this app's modules resolve by token, bound for the worker.
//
// A module resolves a port by token and never imports an adapter (spec §5.6), so the adapters are
// chosen here, in the composition root, from the deployment contract. The platform's services
// themselves are built by `platformClients` (config/src/platform.ts), the same function the web
// shell calls, so the two processes cannot talk to different services. Three kinds of binding:
//
// - the platform's own services: Blob for the files module's object store, the audit log and the
// usage meter for the subscribers, the OCR service and the AI gateway for the documents job;
// - the registry, which the files and proposals modules read their extension points from;
// - the scope of a job or an outbox delivery, which the worker binds per run as `JOB_SCOPE`
// (kit-jobs-pgboss) and the modules that cannot import that adapter resolve through their own
// token (decision AT-F6). An alias is a factory that reads the per-run binding, so it is
// registered once, on the process's container.
import { excludedPageClassNamesOf, NO_VECTOR_STORE_REASON, type PlatformClients } from "@app/config"
import { ACCOUNT_REGISTRY } from "@app/modules/account"
import { AGENT_SCOPE, actionScopeOf, registryAgentTools } from "@app/modules/agent"
import {
DOCUMENT_EXCLUDED_PAGE_CLASSES,
DOCUMENT_MODEL,
DOCUMENT_OCR,
DOCUMENTS_SCOPE,
MODEL_GATEWAY,
pageClassesOf,
sdkDocumentOcr,
} from "@app/modules/documents"
import {
KNOWLEDGE_EMBEDDINGS,
KNOWLEDGE_SCOPE,
KNOWLEDGE_SEARCH,
platformKnowledgeSearch,
unavailableKnowledgeSearch,
vectorKnowledgeSearch,
} from "@app/modules/knowledge"
import { AGENT_TOOLS } from "@conusai-kit/agent"
import type { Container, Registry } from "@conusai-kit/core"
import { DATA_SOURCE, OBJECT_STORE, VECTOR_STORE } from "@conusai-kit/data"
import type { KyselyDataSource } from "@conusai-kit/data-kysely"
import { createKyselyVectorStore } from "@conusai-kit/data-kysely"
import {
FILES_AUDIT,
FILES_REGISTRY,
FILES_SCOPE,
FILES_USAGE,
type FilesAuditSink,
filesDeps,
platformObjectStore,
} from "@conusai-kit/files"
import { GUIDES_PLATFORM, GUIDES_REGISTRY, guidesPlatform } from "@conusai-kit/guides" // kit:guides
import { JOB_SCOPE } from "@conusai-kit/jobs-pgboss"
import { createKnowledge } from "@conusai-kit/platform"
import { PROPOSALS_AUDIT, PROPOSALS_REGISTRY, PROPOSALS_SCOPE } from "@conusai-kit/proposals"
import { SUPPORT_PLATFORM, supportPlatform } from "@conusai-kit/support" // kit:support
import { createQdrantVectorStore, ensureQdrantCollection } from "@conusai-kit/vector-qdrant"
import { QdrantClient } from "@qdrant/js-client-rest"
import { bindBetterAuthAccountEnd } from "./better-auth.js" // kit:convex-better-auth
export function bindModulePorts(container: Container, platform: PlatformClients, registry: Registry): void {
container.value(OBJECT_STORE, platformObjectStore({ blob: platform.objectStore }))
container.value(FILES_REGISTRY, registry)
container.value(PROPOSALS_REGISTRY, registry)
// The account module's sweep resumes a deletion a module's erasure held up: it reads the installed
// modules' shares from the registry and reaches their services through the same ports the web binds.
container.value(ACCOUNT_REGISTRY, registry)
// The sweep resumes a deletion at the step that ends the person's Better Auth account.
bindBetterAuthAccountEnd(container, process.env) // kit:convex-better-auth
// kit:guides:begin -- the guides module's erasure, for a resumed account deletion.
container.value(GUIDES_REGISTRY, registry)
container.value(
GUIDES_PLATFORM,
guidesPlatform({
apiUrl: platform.deployment.CONUSAI_API_URL,
token: platform.deployment.CONUSAI_API_TOKEN,
fake: platform.mode === "fake",
}),
)
// kit:guides:end
// kit:support:begin -- the support module's erasure, for a resumed account deletion.
container.value(
SUPPORT_PLATFORM,
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
// The modules' sinks take the action as a plain string; the registry the audit client was built
// from (config's `auditEventsOf`) is what makes it a known one, and the client refuses the rest.
const sink: FilesAuditSink = {
log: (action, fields) => platform.audit.log(action, fields as Parameters<typeof platform.audit.log>[1]),
}
container.value(FILES_AUDIT, sink)
container.value(PROPOSALS_AUDIT, sink)
container.value(FILES_USAGE, {
track: (event) => platform.usage.track(event),
flush: async () => {
await platform.usage.flush()
},
})
// The platform's asynchronous OCR job API: a job submits and polls, a request never does.
container.value(DOCUMENT_OCR, sdkDocumentOcr(platform.documents))
// The AI gateway the documents job extracts lines through, and the model it asks for (AI_MODEL).
container.value(MODEL_GATEWAY, platform.models)
container.value(DOCUMENT_MODEL, platform.model)
// The page classes the extraction never asks the model about; unset, the module's default (drawings and empty pages).
const excluded = excludedPageClassNamesOf()
if (excluded) container.value(DOCUMENT_EXCLUDED_PAGE_CLASSES, pageClassesOf(excluded))
// The per-run scope, under each module's own name for it.
for (const alias of [FILES_SCOPE, PROPOSALS_SCOPE, DOCUMENTS_SCOPE, KNOWLEDGE_SCOPE, AGENT_SCOPE]) {
container.register(alias, (c) => c.resolve(JOB_SCOPE), { lifetime: "scoped" })
}
// The knowledge module's search backend and the agent runtime's tools, bound for the worker.
bindKnowledgePorts(container, platform)
bindAgentPorts(container, platform)
}
const KNOWLEDGE_COLLECTION = "knowledge"
/**
* The knowledge module's ports. The vector store binds the Qdrant adapter when a Qdrant service is
* linked (QDRANT_URL), the pgvector adapter over the app's own Postgres only when chosen
* (KNOWLEDGE_VECTOR_STORE=pgvector), and otherwise no store: the search service then answers
* unavailable with the reason. The search service binds the local vectors backend or the managed
* documents-index service per KNOWLEDGE_BACKEND.
* Indexing and search jobs run in this process only; a request writes the outbox event and answers.
*/
function bindKnowledgePorts(container: Container, platform: PlatformClients): void {
container.value(KNOWLEDGE_EMBEDDINGS, {
embed: (texts, options) => {
const embed = platform.models.embed
if (!embed) {
throw new Error("the AI gateway has no embeddings model configured (AI_EMBEDDING_MODEL)")
}
return embed.call(platform.models, texts, options)
},
})
if (platform.knowledge.backend === "platform") {
container.register(KNOWLEDGE_SEARCH, (c) =>
platformKnowledgeSearch(createKnowledge(platform.deployment), filesDeps(c)),
)
return
}
if (platform.knowledge.vectorStore === "qdrant" && platform.knowledge.qdrantUrl) {
const client = new QdrantClient({
url: platform.knowledge.qdrantUrl,
...(platform.knowledge.qdrantApiKey === undefined ? {} : { apiKey: platform.knowledge.qdrantApiKey }),
})
// The collection exists, with its payload indexes, before the worker serves a queue.
void ensureQdrantCollection(client, { collection: KNOWLEDGE_COLLECTION, dimensions: platform.knowledge.dimensions })
const store = createQdrantVectorStore({
client,
collection: KNOWLEDGE_COLLECTION,
dimensions: platform.knowledge.dimensions,
})
container.value(VECTOR_STORE, store)
container.value(KNOWLEDGE_SEARCH, vectorKnowledgeSearch({ embeddings: knowledgeEmbeddings(platform), store }))
return
}
if (platform.knowledge.vectorStore !== "pgvector") {
// No store chosen (no Qdrant linked, pgvector not opted into): the honest unavailable service,
// never a silent pgvector default on a Postgres that may not have the extension.
container.value(KNOWLEDGE_SEARCH, unavailableKnowledgeSearch(NO_VECTOR_STORE_REASON))
return
}
// Lazy, like the web shell's: the pgvector adapter resolves the process data source only when a
// job actually searches or indexes, which keeps a bare container bindable in tests.
container.register(VECTOR_STORE, (c) => {
const data = c.resolve(DATA_SOURCE) as KyselyDataSource
return createKyselyVectorStore({
db: data.db,
inScope: (scope, run) => data.inScope(scope, run),
dimensions: platform.knowledge.dimensions,
})
})
container.register(KNOWLEDGE_SEARCH, (c) =>
vectorKnowledgeSearch({ embeddings: knowledgeEmbeddings(platform), store: c.resolve(VECTOR_STORE) }),
)
}
const knowledgeEmbeddings = (platform: PlatformClients): import("@app/modules/knowledge").KnowledgeEmbeddings => ({
embed: (texts, options) => {
const embed = platform.models.embed
if (!embed) {
throw new Error("the AI gateway has no embeddings model configured (AI_EMBEDDING_MODEL)")
}
return embed.call(platform.models, texts, options)
},
})
/** The agent runtime's tools: the organization's registry, with the app's scoped credentials. */
function bindAgentPorts(container: Container, platform: PlatformClients): void {
container.value(
AGENT_TOOLS,
registryAgentTools({
apiUrl: platform.deployment.CONUSAI_API_URL,
token: platform.deployment.CONUSAI_API_TOKEN,
orgId: platform.deployment.CONUSAI_ORG_ID ?? "0",
// Resolved per call: jobs of different tenants share this one process binding, and the
// registry's tool-actions API is addressed by slugs, not ids.
scope: async () => {
const scope = container.resolve(AGENT_SCOPE)
if (scope.level !== "project") throw new Error("agent tools resolve in a project scope")
return actionScopeOf(container.resolve(DATA_SOURCE), scope)
},
}),
)
}

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