This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user