Files

224 lines
8.3 KiB
TypeScript

// The app wired the way apps/web and apps/worker wire the account module -- the registry from the
// manifests, a memory data source, the tenancy hooks, the account ports and the opt-in modules'
// recording platforms -- plus `flaky`, a module whose share of an account can be made to fail, so
// the partial-failure and resume paths are proven whichever opt-in modules this app kept.
import { createHash } from "node:crypto"
import { sessions } from "@conusai-kit/auth"
import {
type Actor,
AppError,
buildRegistry,
Container,
contribute,
createCaller,
defineModule,
type ModuleManifest,
type Registry,
type TransportCaller,
} from "@conusai-kit/core"
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA, toInstant } from "@conusai-kit/data"
import { GUIDES_PLATFORM, GUIDES_REGISTRY } from "@conusai-kit/guides" // kit:guides
import { recordingGuidesPlatform } from "@conusai-kit/guides/testing" // kit:guides
import { SUPPORT_PLATFORM } from "@conusai-kit/support" // kit:support
import { recordingSupportPlatform } from "@conusai-kit/support/testing" // kit:support
import { accountData, createTenancyHooks, requireAppUser, TENANCY } from "@conusai-kit/tenancy"
import { appModules } from "../../index.js"
import {
ACCOUNT_CREDENTIAL,
ACCOUNT_IDENTITY_PROVIDER,
ACCOUNT_PRIVACY,
ACCOUNT_REGISTRY,
type AccountCredential,
type AccountIdentityProvider,
type AccountPrivacy,
} from "../index.js"
export const ISSUER = "https://id.example.test"
/** A stand-in for the hash of a person's session handle, distinct per person as the real one is. */
export const handleHashOf = (email: string): string => createHash("sha256").update(email).digest("hex")
/**
* The app's modules without `removed`, as `kit template:prune --without` leaves them: the module, and
* every other module's contribution to an extension point the removed module declared.
*/
export function modulesWithout(...removed: string[]): ModuleManifest[] {
return appModules
.filter((module) => !removed.includes(module.id))
.map((module) => ({
...module,
extends: (module.extends ?? []).filter(
(contribution) => !removed.includes(contribution.point.id.split(".")[0] ?? ""),
),
}))
}
/** What `flaky` does when asked, and what it saw. */
export interface FlakyState {
exportFails: boolean
eraseFails: boolean
readonly exported: string[]
readonly erased: string[]
/** Called inside each erasure, before it answers: a test looks at the stored rows at that moment. */
onErase?: (subject: string) => Promise<void>
}
const unavailable = () =>
new AppError({ type: "/problems/flaky_unavailable", title: "Unavailable", status: 503, code: "flaky_unavailable" })
export function flakyModule(state: FlakyState): ModuleManifest {
return defineModule({
id: "flaky",
version: "0.1.0",
kit: "^0.1.0",
requires: ["tenancy"],
extends: [
contribute(accountData, [
{
key: "flaky",
async export({ subject }) {
if (state.exportFails) throw unavailable()
state.exported.push(subject)
return { subject, items: 1 }
},
async erase({ subject }) {
await state.onErase?.(subject)
if (state.eraseFails) throw unavailable()
state.erased.push(subject)
},
},
]),
],
})
}
/**
* The memory data source, with the session table's listing made to fail while `failing.sessions` is
* set: the first step of a deletion (revoking the person's live sessions) then throws, as a database
* outage would, after the ledger row was written.
*/
export function failingSessions(data: MemoryData): { data: MemoryData; failing: { sessions: boolean } } {
const failing = { sessions: false }
const outage = () => {
throw new AppError({ type: "/problems/unavailable", title: "Unavailable", status: 503, code: "unavailable" })
}
const wrapped = new Proxy(data, {
get(target, property, receiver) {
const value = Reflect.get(target, property, receiver)
if (property !== "global") return typeof value === "function" ? value.bind(target) : value
return (entity: Parameters<MemoryData["global"]>[0]) => {
const repository = target.global(entity)
if ((entity as unknown) !== sessions) return repository
return new Proxy(repository, {
get(repo, name, r) {
const method = Reflect.get(repo, name, r)
if (typeof method !== "function") return method
return (...args: unknown[]) => (failing.sessions && name === "list" ? outage() : method.apply(repo, args))
},
})
}
},
})
return { data: wrapped, failing }
}
export const newFlaky = (): FlakyState => ({ exportFails: false, eraseFails: false, exported: [], erased: [] })
export type MemoryData = ReturnType<typeof createMemoryDataSource>
export interface Person {
readonly actor: Actor & { readonly userId: string }
readonly email: string
readonly subject: string
readonly sessionId: string
/** The person's caller, as the web binds it for a browser session (or the credential given). */
call(credential?: AccountCredential): TransportCaller
}
export function makeApp(
options: {
modules?: readonly ModuleManifest[]
data?: MemoryData
/** Bound in the process container: every request and the worker's sweep have it. */
identityProvider?: AccountIdentityProvider
/** Bound in each request's container only: a sweep in this app runs without it. */
requestIdentityProvider?: AccountIdentityProvider
/** The platform's privacy service (ADR-049), bound in the process container. */
privacy?: AccountPrivacy
} = {},
) {
const registry: Registry = buildRegistry([...(options.modules ?? appModules)])
const data = options.data ?? createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
const root = new Container()
root.value(DATA_SOURCE, data)
root.value(SYSTEM_DATA, data)
root.value(ACCOUNT_REGISTRY, registry)
if (options.identityProvider) root.value(ACCOUNT_IDENTITY_PROVIDER, options.identityProvider)
if (options.privacy) root.value(ACCOUNT_PRIVACY, options.privacy)
const guides = recordingGuidesPlatform() // kit:guides
root.value(GUIDES_PLATFORM, guides) // kit:guides
root.value(GUIDES_REGISTRY, registry) // kit:guides
const support = recordingSupportPlatform() // kit:support
root.value(SUPPORT_PLATFORM, support) // kit:support
const callerFor = (actor: Actor, credential: AccountCredential): TransportCaller => {
const tenancy = createTenancyHooks({ data, registry })
const container = root.child()
container.value(TENANCY, tenancy)
container.value(ACCOUNT_CREDENTIAL, credential)
if (options.requestIdentityProvider) container.value(ACCOUNT_IDENTITY_PROVIDER, options.requestIdentityProvider)
return createCaller({ registry, container, tenancy }, { actor })
}
/** A first sign-in, as kit-auth's callback makes it: the user, the identity mapping, a session row. */
const signUp = async (email: string): Promise<Person> => {
const subject = `sub-${email}`
const user = await requireAppUser(data, {
issuer: ISSUER,
subject,
email,
displayName: email.split("@")[0] ?? email,
locale: "en",
})
const now = Date.now()
const session = await data.global(sessions).insert({
userId: user.id,
issuer: ISSUER,
subject,
handleHash: handleHashOf(email),
tokens: "sealed-tokens-of-the-test",
accessTokenExpiresAt: toInstant(now + 3600_000),
lastSeenAt: toInstant(now),
absoluteExpiresAt: toInstant(now + 14 * 86400_000),
revokedAt: null,
})
const actor = { userId: user.id }
const browser: AccountCredential = { interactive: true, sessionId: session.id }
return {
actor,
email,
subject,
sessionId: session.id,
call: (credential = browser) => callerFor(actor, credential),
}
}
/** The worker's run of the sweep job: a scopeless job, in a container with no credential. */
const sweep = async (): Promise<void> => {
const job = registry.jobs["account.deletion.sweep"]
if (!job) throw new Error("the account module declares no sweep job")
await job.def.handler(null, { container: root.child(), correlationId: "test-sweep", attempt: 1 })
}
return {
registry,
data,
root,
signUp,
sweep,
guides, // kit:guides
support, // kit:support
}
}