@fabrika/app 0.4.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -8
- package/skill/SKILL.md +12 -6
- package/skill/references/app-setup.md +11 -10
- package/skill/references/dev.md +20 -11
- package/skill/references/endpoint.md +6 -4
- package/skill/references/files.md +39 -35
- package/skill/references/jobs.md +70 -12
- package/skill/references/public-intake.md +360 -191
- package/skill/references/tests.md +10 -1
- package/src/bun.ts +49 -6
- package/src/contract/artifact-schema.ts +3 -0
- package/src/contract/build.ts +51 -5
- package/src/contract/context.ts +21 -6
- package/src/contract/declaration.ts +6 -2
- package/src/contract/group.ts +22 -4
- package/src/contract/kit.ts +19 -4
- package/src/implement/boot.ts +38 -1
- package/src/implement/dispatch.ts +15 -3
- package/src/implement/implement.ts +3 -0
- package/src/implement/index.ts +10 -1
- package/src/implement/invocation.ts +38 -6
- package/src/implement/serve.ts +4 -0
- package/src/jobs/mail.ts +261 -0
- package/src/jobs/runner.ts +129 -32
- package/src/jobs/scrub.ts +1 -0
- package/src/jobs/types.ts +32 -0
- package/src/testing/dev.ts +55 -1
- package/src/testing/endpoint-access.ts +16 -1
- package/src/testing/index.ts +3 -0
- package/src/testing/kit.ts +44 -4
- package/src/testing/membership.ts +13 -3
package/src/testing/kit.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from '@fabrika/auth-core'
|
|
27
27
|
import type { Database } from '@fabrika/db'
|
|
28
28
|
import { refuseInProduction } from '@fabrika/db/production'
|
|
29
|
+
import { MemoryEmailSender } from '@fabrika/email/memory'
|
|
29
30
|
import type { FileObjectStore } from '@fabrika/platform'
|
|
30
31
|
import { type IamGateway, REQUEST_ID_HEADER, stripFabrikaCookies } from '@fabrika/proxy-core'
|
|
31
32
|
import { exportJWK, generateKeyPair, SignJWT } from 'jose'
|
|
@@ -44,6 +45,7 @@ import { type ConnectFetch, connectVariables } from '../implement/connect'
|
|
|
44
45
|
import type { SpaAssets } from '../implement/dispatch'
|
|
45
46
|
import type { ApplicationEnvironment, ContractImplementation } from '../implement/implement'
|
|
46
47
|
import type { WorkloadKindOf, WorkloadTokenSource } from '../implement/workload'
|
|
48
|
+
import type { MailKeyScope } from '../jobs/mail'
|
|
47
49
|
import type { HandlerErrorSink } from '../jobs/runner'
|
|
48
50
|
import type { JobRunOptions, JobRunResult } from '../jobs/runner'
|
|
49
51
|
import {
|
|
@@ -106,6 +108,13 @@ export interface TestKitOptions {
|
|
|
106
108
|
readonly challengeFetch?: ChallengeFetch
|
|
107
109
|
/** What a job's handler threw, shown on the developer's own machine: `fabrika app dev` sets it, a test need not. */
|
|
108
110
|
readonly revealHandlerErrors?: HandlerErrorSink
|
|
111
|
+
/**
|
|
112
|
+
* The stand-in for every job's mail transport; a new `MemoryEmailSender` by default. Pass one with other capabilities
|
|
113
|
+
* to model another provider: `new MemoryEmailSender({ capabilities: { idempotent: false, confirmation: 'queued' } })`.
|
|
114
|
+
* `'none'` models an installation without a transport: `ctx.mail.available` is false, and every send fails
|
|
115
|
+
* `mail_not_configured`.
|
|
116
|
+
*/
|
|
117
|
+
readonly mail?: MemoryEmailSender | 'none'
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
export interface TestConnection {
|
|
@@ -147,11 +156,21 @@ export interface TestKit<C extends AnyContract> {
|
|
|
147
156
|
acceptInvitation(invitationId: string, person?: LabelledTestCaller): LabelledTestCaller
|
|
148
157
|
/**
|
|
149
158
|
* Runs the cron job `name` once, as `fabrika job run <name>` does: under its lease, with the actions IAM signs for
|
|
150
|
-
* its key, its writes fenced. It first waits for the runs a form's `then` kicked, so it never races them.
|
|
159
|
+
* its key, its writes fenced. It first waits for the runs a form's or a mutation's `then` kicked, so it never races them.
|
|
151
160
|
* `FILE_SCAN_JOB` runs the scan of `scan: true` collections with the kit's `scanner`, `FILE_SWEEP_JOB` the sweep of
|
|
152
161
|
* removed and orphaned files. Needs the application's data layer on Postgres.
|
|
153
162
|
*/
|
|
154
163
|
runJob(name: JobName<C> | typeof FILE_SCAN_JOB | typeof FILE_SWEEP_JOB, options?: JobRunOptions): Promise<JobRunResult>
|
|
164
|
+
/** Waits for the work requests left running: the runs a form's or a mutation's `then` kicked, a failure's report. */
|
|
165
|
+
drain(): Promise<void>
|
|
166
|
+
/**
|
|
167
|
+
* The stand-in for every job's transport: what reached it (`accepted`, `handoffs`) and its faults (`refuse`,
|
|
168
|
+
* `unavailable`, `unreachable`, `loseAnswers`, `during`, `heal`). Each message carries the transport key,
|
|
169
|
+
* `transportMailKey(kit.mailKeyScope, key)`. Under `mail: 'none'` nothing reaches it.
|
|
170
|
+
*/
|
|
171
|
+
readonly mail: MemoryEmailSender
|
|
172
|
+
/** Whose keys this kit's prefix separates: `mailKeyPrefix(kit.mailKeyScope)` is what a job's key gets in front. */
|
|
173
|
+
readonly mailKeyScope: MailKeyScope
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
/** The names of the contract's cron jobs. */
|
|
@@ -308,6 +327,8 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
308
327
|
// issues the proxy's delegation grants.
|
|
309
328
|
const standIn = options.membership === undefined ? kitMembership(artifact, directoryCallers) : null
|
|
310
329
|
const membership = options.membership ?? standIn?.rpc
|
|
330
|
+
const mail = options.mail === undefined || options.mail === 'none' ? new MemoryEmailSender() : options.mail
|
|
331
|
+
const mailKeyScope: MailKeyScope = Object.freeze({ issuer: TEST_ISSUER, appId })
|
|
311
332
|
const application: ContractApp = boot(app, {
|
|
312
333
|
environment: {
|
|
313
334
|
// The deploy writes the approved contract's digest; the kit's contract is the approved one by definition.
|
|
@@ -331,6 +352,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
331
352
|
membership,
|
|
332
353
|
connectBindings,
|
|
333
354
|
workloadTokens,
|
|
355
|
+
mailStandIn: options.mail === 'none' ? 'none' : mail,
|
|
334
356
|
})
|
|
335
357
|
|
|
336
358
|
let issued = 0
|
|
@@ -495,7 +517,7 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
495
517
|
|
|
496
518
|
const proxy = endpointProxy({ artifact, appId, host, iam, issuer: TEST_ISSUER, delegations: membership ?? null })
|
|
497
519
|
|
|
498
|
-
// What requests leave running: a job a form's `then` kicked, or a failure's report.
|
|
520
|
+
// What requests leave running: a job a form's or a mutation's `then` kicked, or a failure's report.
|
|
499
521
|
const background = new Set<Promise<void>>()
|
|
500
522
|
const exec: RequestExecutionContext = {
|
|
501
523
|
waitUntil(promise) {
|
|
@@ -550,10 +572,14 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
550
572
|
}
|
|
551
573
|
}
|
|
552
574
|
|
|
553
|
-
const
|
|
575
|
+
const drain = async (): Promise<void> => {
|
|
554
576
|
while (background.size > 0) {
|
|
555
577
|
await Promise.all(background)
|
|
556
578
|
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const runJob = async (name: string, runOptions?: JobRunOptions): Promise<JobRunResult> => {
|
|
582
|
+
await drain()
|
|
557
583
|
return application.jobs.run(name, runOptions)
|
|
558
584
|
}
|
|
559
585
|
|
|
@@ -578,7 +604,21 @@ export function bootTestKit<C extends AnyContract>(
|
|
|
578
604
|
return invitee
|
|
579
605
|
}
|
|
580
606
|
return {
|
|
581
|
-
kit: Object.freeze({
|
|
607
|
+
kit: Object.freeze({
|
|
608
|
+
origin,
|
|
609
|
+
appId,
|
|
610
|
+
artifact,
|
|
611
|
+
testCaller,
|
|
612
|
+
scope,
|
|
613
|
+
fetch: kitFetch,
|
|
614
|
+
browser,
|
|
615
|
+
iam,
|
|
616
|
+
runJob,
|
|
617
|
+
drain,
|
|
618
|
+
acceptInvitation,
|
|
619
|
+
mail,
|
|
620
|
+
mailKeyScope,
|
|
621
|
+
}),
|
|
582
622
|
standIn,
|
|
583
623
|
callerOf: (value) => [...memberships.keys()].find((caller) => caller === value),
|
|
584
624
|
}
|
|
@@ -144,6 +144,8 @@ export interface SentInvitation {
|
|
|
144
144
|
readonly role: string
|
|
145
145
|
readonly scope: Scope
|
|
146
146
|
readonly expiresAt: number
|
|
147
|
+
/** When it was last sent, in seconds: when it was opened, or its latest resend. */
|
|
148
|
+
readonly sentAt: number
|
|
147
149
|
readonly status: 'open' | 'accepted' | 'cancelled' | 'expired'
|
|
148
150
|
}
|
|
149
151
|
|
|
@@ -165,6 +167,7 @@ interface KitInvitation {
|
|
|
165
167
|
readonly role: string
|
|
166
168
|
readonly email: string
|
|
167
169
|
readonly expiresAt: number
|
|
170
|
+
readonly sentAt: number
|
|
168
171
|
readonly closed: 'cancelled' | 'accepted' | null
|
|
169
172
|
/** The principal that opened it, whose authority IAM checks again at acceptance; `null` for a seeded one. */
|
|
170
173
|
readonly inviter: string | null
|
|
@@ -479,11 +482,17 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
|
|
|
479
482
|
)
|
|
480
483
|
|
|
481
484
|
const openNew = (scope: Scope, role: string, email: string, inviter: string | null): KitInvitation => {
|
|
482
|
-
const
|
|
485
|
+
const sentAt = now()
|
|
486
|
+
const invitation = { id: `invitation-${randomId()}`, scope, role, email, expiresAt: sentAt + INVITATION_TTL_SECONDS, sentAt, closed: null, inviter }
|
|
483
487
|
invitations.set(invitation.id, invitation)
|
|
484
488
|
return invitation
|
|
485
489
|
}
|
|
486
490
|
|
|
491
|
+
const resent = (invitation: KitInvitation): KitInvitation => {
|
|
492
|
+
const sentAt = now()
|
|
493
|
+
return { ...invitation, expiresAt: sentAt + INVITATION_TTL_SECONDS, sentAt }
|
|
494
|
+
}
|
|
495
|
+
|
|
487
496
|
/** Spends the request's grant before its binding is compared, as IAM does, so each attempt is single use. */
|
|
488
497
|
const spend = (caller: TestCaller, input: { delegation: string; requestId: string }, purpose: EndpointDelegation, dimension: string) => {
|
|
489
498
|
const grant = grants.get(input.delegation)
|
|
@@ -595,7 +604,7 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
|
|
|
595
604
|
const invitation = open === undefined
|
|
596
605
|
? openNew(input.scope, input.role, email, caller.id)
|
|
597
606
|
: input.invitation?.resend === true
|
|
598
|
-
?
|
|
607
|
+
? resent(open)
|
|
599
608
|
: open
|
|
600
609
|
invitations.set(invitation.id, invitation)
|
|
601
610
|
asked.push({
|
|
@@ -751,12 +760,13 @@ export function kitMembership(artifact: ContractArtifact, callers: DirectoryCall
|
|
|
751
760
|
return link === undefined || link.revoked ? undefined : { id: link.id, mount: link.mount, chain: link.chain }
|
|
752
761
|
}
|
|
753
762
|
const invitationsSent = (): readonly SentInvitation[] =>
|
|
754
|
-
[...invitations.values()].map(({ id, email, role, scope, expiresAt, closed }) => ({
|
|
763
|
+
[...invitations.values()].map(({ id, email, role, scope, expiresAt, sentAt, closed }) => ({
|
|
755
764
|
id,
|
|
756
765
|
email,
|
|
757
766
|
role,
|
|
758
767
|
scope,
|
|
759
768
|
expiresAt,
|
|
769
|
+
sentAt,
|
|
760
770
|
status: closed ?? (expiresAt > now() ? 'open' : 'expired'),
|
|
761
771
|
}))
|
|
762
772
|
const accept = (invitationId: string, recipient?: TestCaller): AcceptedInvitation => {
|