This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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"),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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 |
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 "/"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")),
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The derived service sessions of one device (joint contract §14 "Lifecycle, CSP and revocation"):
|
||||
// when kit-auth revokes an app session -- sign-out, a refused refresh, expiry -- every installed
|
||||
// module's `tenancy.account` share revokes, server-side, the service sessions it minted for that
|
||||
// session (a Support widget session records it as its parent). An account deletion revokes the
|
||||
// subject's sessions itself, at its start (modules/account/core/deletion.ts).
|
||||
//
|
||||
// Durable on both profiles: the revocation is written to the account module's revocation outbox and
|
||||
// delivered at once, and what a platform did not confirm is retried with backoff
|
||||
// (modules/account/core/revocation.ts) -- by the worker on Postgres, and on Convex by the backend,
|
||||
// whose `account:revokeSession` action records and delivers it and whose one-minute cron retries it
|
||||
// (convex/account.ts). When nothing was recorded (a refused write, a backend that did not answer)
|
||||
// the modules are asked directly, bounded, once.
|
||||
|
||||
import { type SessionRef, sessionRefKey } from "@conusai-kit/auth"
|
||||
import type { Container, Registry } from "@conusai-kit/core"
|
||||
import { accountDataOf } from "@conusai-kit/tenancy"
|
||||
|
||||
/** How long a sign-out waits for one module's revocation before it goes on. */
|
||||
export const DERIVED_REVOCATION_MS = 3000
|
||||
|
||||
export interface DerivedSessionPorts {
|
||||
readonly registry: Registry
|
||||
/** The process container: a child of it resolves each module's platform port. */
|
||||
readonly root: Container
|
||||
readonly log: { warn(message: string): void }
|
||||
readonly timeoutMs?: number
|
||||
/**
|
||||
* Records the revocation durably and delivers it (the account module's `recordSessionRevocation`),
|
||||
* when this profile has the outbox. Throws only when nothing was recorded.
|
||||
*/
|
||||
readonly durable?: (session: string) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded and never thrown: kit-auth keeps a session live when its hook throws, which would fail the
|
||||
* sign-out. With the outbox, a module that could not be reached is retried by the worker until its
|
||||
* platform confirms. Without it, such a module is logged by name and its sessions end at their own
|
||||
* expiry (at most 60 minutes); this app already refuses the revoked session's requests.
|
||||
*/
|
||||
export async function revokeDerivedSessions(ports: DerivedSessionPorts, ref: SessionRef): Promise<void> {
|
||||
const session = sessionRefKey(ref)
|
||||
if (ports.durable) {
|
||||
try {
|
||||
await ports.durable(session)
|
||||
return
|
||||
} catch {
|
||||
ports.log.warn("auth: the revocation of an app session could not be recorded; asking the modules directly")
|
||||
}
|
||||
}
|
||||
const timeoutMs = ports.timeoutMs ?? DERIVED_REVOCATION_MS
|
||||
const contributions = accountDataOf(ports.registry).filter((contribution) => contribution.revokeSessions)
|
||||
const settled = await Promise.allSettled(
|
||||
contributions.map((contribution) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error("timed out")), timeoutMs)
|
||||
})
|
||||
return Promise.race([contribution.revokeSessions?.({ session, container: ports.root.child() }), timeout]).finally(
|
||||
() => clearTimeout(timer),
|
||||
)
|
||||
}),
|
||||
)
|
||||
settled.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
ports.log.warn(
|
||||
`auth: ${contributions[index]?.key ?? "a module"} did not revoke the sessions of a revoked app session`,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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" } : {}),
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
})
|
||||
}
|
||||
@@ -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}`
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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) },
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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}` : ""}`
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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}`,
|
||||
)
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user