224 lines
8.3 KiB
TypeScript
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
|
|
}
|
|
}
|