@fabrika/app 0.3.3 → 0.4.1

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.
Files changed (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +746 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +308 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +331 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +824 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +760 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. package/src/testing.ts +0 -119
@@ -0,0 +1,121 @@
1
+ // A service contract: the endpoint table of one of the installation's own services (ADR-0012, ADR-0022 "Installation
2
+ // entries"). Plain data over `@fabrika/auth-core` only, so a table module stays dependency-light.
3
+
4
+ import {
5
+ APP_ENVIRONMENT_SEPARATOR,
6
+ type BodyAdmission,
7
+ compileEndpointTable,
8
+ EndpointTableError,
9
+ type InstallationCaller,
10
+ type InstallationEndpointMethod,
11
+ type InstallationEndpointRow,
12
+ type InstallationEndpointTable,
13
+ OAUTH_CALLER,
14
+ } from '@fabrika/auth-core'
15
+
16
+ /** A row as the builder writes it; the record key of `endpoints` becomes its id. */
17
+ export type ServiceEndpointSpec = InstallationEndpointRow extends infer Row ? Row extends InstallationEndpointRow ? Omit<Row, 'id'> : never : never
18
+
19
+ /** One installation service's table, with the row ids as a type so its handlers can be checked against them. */
20
+ export interface ServiceArtifact<Id extends string = string> extends InstallationEndpointTable {
21
+ /** The service's IAM app id; never `<app>@<env>`. */
22
+ readonly id: string
23
+ readonly endpoints: readonly (InstallationEndpointRow & { readonly id: Id })[]
24
+ }
25
+
26
+ export interface ServiceEndpointOptions {
27
+ readonly callers: readonly [InstallationCaller, ...InstallationCaller[]]
28
+ /** The request body the row admits; omitted admits none. */
29
+ readonly admission?: BodyAdmission
30
+ /** The scopes an OAuth resource token must carry; required exactly when `callers` holds `oauth`. */
31
+ readonly scopes?: readonly string[]
32
+ }
33
+
34
+ export interface ServiceContractBuilder {
35
+ /** A liveness endpoint: anonymous, and served without `X-Fabrika-Endpoint`. */
36
+ health(method: InstallationEndpointMethod, path: string): ServiceEndpointSpec
37
+ /** An endpoint the service authenticates itself; the proxy reads no credential and keeps `Authorization`. */
38
+ protocol(method: InstallationEndpointMethod, path: string): ServiceEndpointSpec
39
+ /** A credentialed endpoint: a listed caller kind, and no permission (`authenticated`). */
40
+ service(method: InstallationEndpointMethod, path: string, options: ServiceEndpointOptions): ServiceEndpointSpec
41
+ }
42
+
43
+ export interface ServiceContractDeclaration<Id extends string> {
44
+ /** `null` is a row this composition does not serve: the table has no row there, and its handler is never called. */
45
+ readonly endpoints: Readonly<Record<Id, ServiceEndpointSpec | null>>
46
+ /** Literal prefixes a path under which reaches only a row declared under it. */
47
+ readonly reservedPrefixes: readonly string[]
48
+ }
49
+
50
+ export class ServiceContractError extends Error {
51
+ override readonly name = 'ServiceContractError'
52
+ readonly problems: readonly string[]
53
+
54
+ constructor(problems: readonly string[]) {
55
+ super(`the service contract is invalid:\n- ${problems.join('\n- ')}`)
56
+ this.problems = problems
57
+ }
58
+ }
59
+
60
+ const builder: ServiceContractBuilder = {
61
+ health: (method, path) => anonymous('health', method, path),
62
+ protocol: (method, path) => anonymous('protocol', method, path),
63
+ service: (method, path, options) => ({
64
+ kind: 'service',
65
+ method,
66
+ path,
67
+ callers: options.callers,
68
+ requirement: { kind: 'authenticated' },
69
+ admission: options.admission ?? null,
70
+ oauth: options.scopes === undefined ? null : { scopes: options.scopes },
71
+ }),
72
+ }
73
+
74
+ function anonymous(kind: 'health' | 'protocol', method: InstallationEndpointMethod, path: string): ServiceEndpointSpec {
75
+ return { kind, method, path, callers: [], requirement: null, admission: null, oauth: null }
76
+ }
77
+
78
+ /**
79
+ * Declare one installation service's endpoint table. Validated here, once: an id that is an application environment,
80
+ * an `oauth` caller without its scopes (or scopes without it), and every table the matcher would refuse.
81
+ */
82
+ export function defineServiceContract<const Id extends string>(
83
+ id: string,
84
+ declare: (s: ServiceContractBuilder) => ServiceContractDeclaration<Id>,
85
+ ): ServiceArtifact<Id> {
86
+ const declaration = declare(builder)
87
+ const endpoints = servedEntries(declaration.endpoints).map(([rowId, spec]) => ({ ...spec, id: rowId }))
88
+ const problems: string[] = []
89
+ if (id === '' || id.includes(APP_ENVIRONMENT_SEPARATOR)) {
90
+ problems.push(`'${id}' is no installation service id: it must be non-empty and hold no '${APP_ENVIRONMENT_SEPARATOR}'`)
91
+ }
92
+ for (const row of endpoints) {
93
+ const admitsOAuth = row.callers.some((caller) => caller === OAUTH_CALLER)
94
+ if (admitsOAuth !== (row.oauth !== null)) {
95
+ problems.push(`'${row.id}' must declare scopes exactly when it admits the caller '${OAUTH_CALLER}'`)
96
+ }
97
+ if (row.oauth !== null && row.oauth.scopes.length === 0) {
98
+ problems.push(`'${row.id}' admits '${OAUTH_CALLER}' with no scope`)
99
+ }
100
+ }
101
+ const table = { endpoints, reservedPrefixes: [...declaration.reservedPrefixes] }
102
+ try {
103
+ compileEndpointTable(table)
104
+ } catch (error) {
105
+ if (!(error instanceof EndpointTableError)) throw error
106
+ problems.push(...error.problems)
107
+ }
108
+ if (problems.length > 0) {
109
+ throw new ServiceContractError(problems)
110
+ }
111
+ return { id, ...table }
112
+ }
113
+
114
+ function servedEntries<Id extends string, Value>(record: Readonly<Record<Id, Value | null>>): [Id, Value][] {
115
+ const entries: [Id, Value][] = []
116
+ for (const key in record) {
117
+ const value = record[key]
118
+ if (Object.hasOwn(record, key) && value !== null) entries.push([key, value])
119
+ }
120
+ return entries
121
+ }
@@ -0,0 +1,21 @@
1
+ // @fabrika/app/service — the installation's own services only (IAM, Control, Operations). An application serves its
2
+ // contract through `serve`; `@fabrika/check` refuses this import in one.
3
+
4
+ export { dispatchRpcRequest } from '../rpc/dispatcher.js'
5
+ export {
6
+ defineServiceContract,
7
+ type ServiceArtifact,
8
+ type ServiceContractBuilder,
9
+ type ServiceContractDeclaration,
10
+ ServiceContractError,
11
+ type ServiceEndpointOptions,
12
+ type ServiceEndpointSpec,
13
+ } from './define.js'
14
+ export {
15
+ serveService,
16
+ type ServeServiceOptions,
17
+ type ServiceHandler,
18
+ type ServiceHandlers,
19
+ ServiceHandlersError,
20
+ type ServiceRequest,
21
+ } from './serve.js'
@@ -0,0 +1,99 @@
1
+ // The dispatcher of a service contract: the service's one router, over the table the proxy decides from.
2
+
3
+ import {
4
+ compileEndpointTable,
5
+ ENDPOINT_HEADER,
6
+ type InstallationEndpointRow,
7
+ parseEndpointHeader,
8
+ planEndpoint,
9
+ rawRequestTarget,
10
+ requirementDigest,
11
+ } from '@fabrika/auth-core'
12
+ import { defaultOnError, jsonResponse } from '../errors.js'
13
+ import type { FetchService, RequestExecutionContext } from '../fetch.js'
14
+ import type { ServiceArtifact } from './define.js'
15
+
16
+ export interface ServiceRequest<Env> {
17
+ readonly request: Request
18
+ /** The `:name` segments of the selected path, percent-decoded. */
19
+ readonly params: Readonly<Record<string, string>>
20
+ readonly env: Env
21
+ readonly exec: RequestExecutionContext
22
+ }
23
+
24
+ export type ServiceHandler<Env> = (input: ServiceRequest<Env>) => Response | Promise<Response>
25
+
26
+ /** One handler per declared row id, also for a row this composition left out; that one is never called. */
27
+ export type ServiceHandlers<Id extends string, Env> = { readonly [K in Id]: ServiceHandler<Env> }
28
+
29
+ export interface ServeServiceOptions<Env> {
30
+ /** Map a value a handler threw to a response. Defaults to the structural error mapping. */
31
+ readonly onError?: (error: unknown, request: Request, env: Env) => Response
32
+ }
33
+
34
+ export class ServiceHandlersError extends Error {
35
+ override readonly name = 'ServiceHandlersError'
36
+ }
37
+
38
+ /**
39
+ * Serve a service contract. Per request: a non-canonical path is a 400, a request no row selects a 404, a `service`
40
+ * row reached without the proxy's `X-Fabrika-Endpoint` for that row and its digest a 503; then the row's handler. A
41
+ * `protocol` or `health` row ignores the header, because the service authenticates it itself and it also arrives
42
+ * unfronted (ADR-0022 rule 5).
43
+ */
44
+ export function serveService<Id extends string, Env>(
45
+ service: ServiceArtifact<Id>,
46
+ handlers: ServiceHandlers<Id, Env>,
47
+ options: ServeServiceOptions<Env> = {},
48
+ ): FetchService<Env> {
49
+ const table = compileEndpointTable<InstallationEndpointRow & { readonly id: Id }>(service)
50
+ const missing = service.endpoints.filter((row) => !Object.hasOwn(handlers, row.id)).map((row) => row.id)
51
+ if (missing.length > 0) {
52
+ throw new ServiceHandlersError(`service '${service.id}' has no handler for ${missing.map((id) => `'${id}'`).join(', ')}`)
53
+ }
54
+ const digests = new Map(service.endpoints.map((row) => [row.id, requirementDigest({ ...row, delegation: null })]))
55
+ const onError = options.onError ?? defaultOnError
56
+
57
+ return {
58
+ async fetch(request, env, exec) {
59
+ const plan = planEndpoint(table, request.method, rawRequestTarget(request.url).pathname)
60
+ if (plan.outcome === 'non-canonical') {
61
+ return jsonResponse({ error: { type: 'bad_request', message: 'The request path is not canonical' } }, 400)
62
+ }
63
+ if (plan.outcome === 'no-endpoint') {
64
+ return jsonResponse({ error: { type: 'not_found', message: 'Not found' } }, 404)
65
+ }
66
+ const row = plan.endpoint
67
+ if (row.kind === 'service' && !namesRow(request.headers.get(ENDPOINT_HEADER), row.id, digests.get(row.id))) {
68
+ console.error(`service '${service.id}': '${row.id}' was reached without the proxy's decision for its rules`)
69
+ return jsonResponse({ error: { type: 'unavailable', message: 'Service unavailable' } }, 503)
70
+ }
71
+ const params = decodeParams(plan.params)
72
+ if (params === null) {
73
+ return jsonResponse({ error: { type: 'bad_request', message: 'A path parameter does not decode' } }, 400)
74
+ }
75
+ try {
76
+ return await handlers[row.id]({ request, params, env, exec })
77
+ } catch (error) {
78
+ return onError(error, request, env)
79
+ }
80
+ },
81
+ }
82
+ }
83
+
84
+ function namesRow(header: string | null, id: string, digest: string | undefined): boolean {
85
+ const named = header === null ? null : parseEndpointHeader(header)
86
+ return named !== null && named.id === id && named.digest === digest
87
+ }
88
+
89
+ function decodeParams(raw: Readonly<Record<string, string>>): Readonly<Record<string, string>> | null {
90
+ const decoded: [string, string][] = []
91
+ for (const [name, value] of Object.entries(raw)) {
92
+ try {
93
+ decoded.push([name, decodeURIComponent(value)])
94
+ } catch {
95
+ return null
96
+ }
97
+ }
98
+ return Object.fromEntries(decoded)
99
+ }
@@ -0,0 +1,123 @@
1
+ // Test callers: a principal of the test IAM with the permissions its grants resolve to, as IAM resolves them, and
2
+ // the credential it presents.
3
+
4
+ import type { PermissionEntry, PrincipalAffiliation } from '@fabrika/auth-core'
5
+ import type { ActionOf, AnyContract, DimensionOf, RoleOf } from '../contract/define'
6
+
7
+ /** `app` is the connector of another application: its key reaches only the exports that application consumes. */
8
+ export type TestCallerKind = 'human' | 'machine' | 'link' | 'app'
9
+
10
+ export interface TestScope<D extends string = string> {
11
+ readonly type: D
12
+ readonly value: string
13
+ }
14
+
15
+ /** An action, or a role whose actions IAM grants together; without a scope it is global. */
16
+ export type TestGrant<C> =
17
+ | { readonly action: ActionOf<C>; readonly scope?: TestScope<DimensionOf<C>> }
18
+ | { readonly role: RoleOf<C>; readonly scope?: TestScope<DimensionOf<C>> }
19
+
20
+ export interface TestCallerOptions {
21
+ /** Defaults to `human`. */
22
+ readonly kind?: TestCallerKind
23
+ /** How a human signs in: the IAM session (default) or a `px_` key bound to the person. Other kinds use a key. */
24
+ readonly via?: 'session' | 'key'
25
+ readonly id?: string
26
+ readonly label?: string
27
+ /** A human's address (`ctx.principal.email`), normalized as IAM stores it. Only a human has one. */
28
+ readonly email?: string
29
+ /**
30
+ * Defaults to `internal`. An `external` human holds only roles the contract declares `external`, as IAM resolves it
31
+ * (ADR-0051); the kit refuses any other grant for one.
32
+ */
33
+ readonly affiliation?: PrincipalAffiliation
34
+ /**
35
+ * An operator disabled the principal in IAM: a member directory lists and finds it with `disabled: true`, and the kit's
36
+ * IAM signs no token for its session or key, so its requests are refused as IAM refuses a disabled principal's.
37
+ */
38
+ readonly disabled?: boolean
39
+ /**
40
+ * When the person last signed in to the application (unix seconds), as IAM records it at the handoff; absent, never.
41
+ * A member directory answers it to a holder of `members.manage` only. Only a human signs in.
42
+ */
43
+ readonly lastSignInAt?: number
44
+ /** The consuming application's id, for an `app` caller. */
45
+ readonly app?: string
46
+ /**
47
+ * What a `link` caller's key is bound to: the link mount, and the object first, then each value containing it. A link
48
+ * caller without one holds a key bound to nothing, which no procedure admits.
49
+ */
50
+ readonly link?: TestLink
51
+ }
52
+
53
+ export interface TestLink {
54
+ readonly mount: string
55
+ readonly chain: readonly TestScope[]
56
+ }
57
+
58
+ interface TestCallerCredential {
59
+ /** The principal id, or the share key's credential id for a link. */
60
+ readonly id: string
61
+ /** `internal` for every caller but an `external` human. */
62
+ readonly affiliation: PrincipalAffiliation
63
+ /** `true` for a principal an operator disabled; `false` for every other caller, a link holder included. */
64
+ readonly disabled: boolean
65
+ /** A human's last sign-in to the application (`TestCallerOptions.lastSignInAt`); `null` for one that never did and every other caller. */
66
+ readonly lastSignInAt: number | null
67
+ readonly perms: readonly PermissionEntry[]
68
+ /** The `px_` key a key caller presents as a bearer; `null` for a session. */
69
+ readonly key: string | null
70
+ /** The `Cookie` header a signed-in browser sends; `null` for a key caller. */
71
+ readonly cookie: string | null
72
+ }
73
+
74
+ /** A person, a script or another application's connector: a principal with a label. */
75
+ export interface LabelledTestCaller extends TestCallerCredential {
76
+ readonly kind: Exclude<TestCallerKind, 'link'>
77
+ /** The consuming application of an `app` caller, `null` for every other kind. */
78
+ readonly app: string | null
79
+ readonly link: null
80
+ readonly label: string
81
+ /** A human's normalized address, which its token carries; `null` for a script and a connector. */
82
+ readonly email: string | null
83
+ }
84
+
85
+ /** A person, whose token carries its address: `ctx.principal.email` of its requests. */
86
+ export interface PersonTestCaller extends LabelledTestCaller {
87
+ readonly kind: 'human'
88
+ readonly email: string
89
+ }
90
+
91
+ /** The holder of a share link: an anonymous key, which carries no label. */
92
+ export interface LinkTestCaller extends TestCallerCredential {
93
+ readonly kind: 'link'
94
+ readonly app: null
95
+ /** The link mount and chain the key is bound to; `null` for a key bound to nothing. */
96
+ readonly link: TestLink | null
97
+ readonly label: null
98
+ readonly email: null
99
+ }
100
+
101
+ export type TestCaller = LabelledTestCaller | LinkTestCaller
102
+
103
+ /** The permissions `grants` resolve to: a role becomes each of its actions in the role's scope. */
104
+ export function resolveGrants<C extends AnyContract>(contract: C, grants: readonly TestGrant<C>[]): PermissionEntry[] {
105
+ const { actions, roles } = contract.vocabulary
106
+ const scopeOf = (scope: TestScope | undefined) => (scope === undefined ? null : { type: scope.type, value: scope.value })
107
+ return grants.flatMap((grant): PermissionEntry[] => {
108
+ if ('action' in grant) {
109
+ if (!Object.hasOwn(actions, grant.action)) {
110
+ throw new Error(`'${grant.action}' is not an action of the contract`)
111
+ }
112
+ return [{ action: grant.action, scope: scopeOf(grant.scope), source: 'grant' }]
113
+ }
114
+ const role = Object.hasOwn(roles, grant.role) ? roles[grant.role] : undefined
115
+ if (role === undefined) {
116
+ throw new Error(`'${grant.role}' is not a role of the contract`)
117
+ }
118
+ if (role.member !== undefined && grant.scope?.type !== role.member.scope) {
119
+ throw new Error(`'${grant.role}' is a membership role: grant it in one ${role.member.scope} (scope: { type: '${role.member.scope}', value })`)
120
+ }
121
+ return role.grants.map((action) => ({ action, scope: scopeOf(grant.scope), source: 'grant' }))
122
+ })
123
+ }
@@ -0,0 +1,10 @@
1
+ // A stand-in for a form challenge's provider, for a test kit and for `fabrika app dev`.
2
+
3
+ import type { ChallengeFetch } from '../implement/challenge'
4
+
5
+ /** The token Turnstile's always-passing test widget (site key `1x00000000000000000000AA`) hands out every time. */
6
+ export const TURNSTILE_TEST_TOKEN = 'XXXX.DUMMY.TOKEN.XXXX'
7
+
8
+ /** Turnstile's siteverify that passes every token: a fresh answer for the host and the form the check expects. */
9
+ export const passingChallenge: ChallengeFetch = (_request, check) =>
10
+ Promise.resolve(Response.json({ success: true, challenge_ts: new Date().toISOString(), hostname: check.hostname, action: check.action }))
@@ -0,0 +1,99 @@
1
+ // The kit `fabrika app dev` previews an application through, and the types of the seed module it runs first.
2
+
3
+ import type { AnyContract, DimensionOf } from '../contract/define'
4
+ import type { ContractImplementation } from '../implement/implement'
5
+ import type { PersonTestCaller, TestCaller } from './caller'
6
+ import { passingChallenge, TURNSTILE_TEST_TOKEN } from './challenge'
7
+ import { bootTestKit, type TestKit, type TestKitOptions } from './kit'
8
+ import type { SentInvitation, TestMembership } from './membership'
9
+
10
+ /** Someone the author clicks through the application as: a person the seed made with `dev.testCaller`. */
11
+ export interface DevPersona {
12
+ /** Shown on the picker's card and in the toolbar, unique among the personas: `Petra`. */
13
+ readonly name: string
14
+ /** One short line of who this is: `Staff of the design workspace`. */
15
+ readonly description?: string
16
+ readonly caller: PersonTestCaller
17
+ }
18
+
19
+ /** A page a link holder opens: a link the seed issued through the application's own endpoint. */
20
+ export interface DevLink {
21
+ readonly title: string
22
+ readonly description?: string
23
+ /** The URL the application answered, a path of its origin: `/s/#px_…`. */
24
+ readonly url: string
25
+ }
26
+
27
+ /** A scope value the seed made, with the name a person knows it by: `{ value: 'w1', label: 'Design team' }`. */
28
+ export interface DevScopeValue {
29
+ readonly value: string
30
+ readonly label: string
31
+ }
32
+
33
+ /** What a seed answers; every part is optional. */
34
+ export interface DevSeedResult<C extends AnyContract> {
35
+ readonly personas?: readonly DevPersona[]
36
+ readonly links?: readonly DevLink[]
37
+ /** The values of each dimension the seed made, which the picker offers for a membership role. */
38
+ readonly scopes?: { readonly [D in DimensionOf<C>]?: readonly DevScopeValue[] }
39
+ }
40
+
41
+ /** The origin `fabrika app dev` captures every request to: an outside provider's stand-in, named by its path. */
42
+ export const DEV_OUTBOX_ORIGIN = 'https://outbox.invalid'
43
+
44
+ /** A request the application sent to `DEV_OUTBOX_ORIGIN` under `fabrika app dev`: its body, never its headers. */
45
+ export interface DevOutboxMessage {
46
+ readonly id: number
47
+ readonly method: string
48
+ /** The path it was sent to: `/mail`. */
49
+ readonly path: string
50
+ readonly contentType: string | null
51
+ readonly body: string
52
+ }
53
+
54
+ /** What `fabrika app dev` stands in for outside the application, for the seed to read. */
55
+ export interface DevOutside {
56
+ /** Every request sent to `DEV_OUTBOX_ORIGIN` so far, oldest first. */
57
+ outbox(): readonly DevOutboxMessage[]
58
+ }
59
+
60
+ /**
61
+ * The default export of `dev/seed.ts`: it receives the application's test kit, the API its tests use, writes through the
62
+ * endpoints, and answers who and what the picker offers. `outside` reads what the application sent to the dev outbox.
63
+ */
64
+ export type DevSeed<C extends AnyContract> = (dev: TestKit<C>, outside: DevOutside) => Promise<DevSeedResult<C>> | DevSeedResult<C>
65
+
66
+ export interface DevKit<C extends AnyContract> {
67
+ readonly kit: TestKit<C>
68
+ /** The person `value` is when this kit made it; `undefined` for any other value. */
69
+ personOf(value: unknown): PersonTestCaller | undefined
70
+ /** The roles a caller of this kit holds now: its grants, with what the application assigned and revoked through IAM. */
71
+ membershipsOf(caller: TestCaller): readonly TestMembership[]
72
+ /** The invitation e-mails the stand-in IAM sent, in the order it first sent them. */
73
+ invitations(): readonly SentInvitation[]
74
+ }
75
+
76
+ const isPerson = (caller: TestCaller): caller is PersonTestCaller => caller.kind === 'human' && caller.email !== null
77
+
78
+ /**
79
+ * The test kit of `app` with its stand-in IAM, and what `fabrika app dev` shows of it. A form's challenge passes
80
+ * (`passingChallenge`) unless `options.challengeFetch` answers it, and admits the test widget's token
81
+ * (`TURNSTILE_TEST_TOKEN`) at every submission; any other token is single use, as deployed.
82
+ */
83
+ export function devKit<C extends AnyContract>(app: ContractImplementation<C>, options: Omit<TestKitOptions, 'membership'> = {}): DevKit<C> {
84
+ const { kit, standIn, callerOf } = bootTestKit(app, { challengeFetch: passingChallenge, ...options }, {
85
+ repeatableChallengeToken: TURNSTILE_TEST_TOKEN,
86
+ })
87
+ if (standIn === null) {
88
+ throw new Error('a kit without its own membership transport stands in for IAM')
89
+ }
90
+ return Object.freeze({
91
+ kit,
92
+ personOf(value: unknown): PersonTestCaller | undefined {
93
+ const caller = callerOf(value)
94
+ return caller !== undefined && isPerson(caller) ? caller : undefined
95
+ },
96
+ membershipsOf: (caller: TestCaller) => standIn.membershipsOf(caller),
97
+ invitations: () => standIn.invitations(),
98
+ })
99
+ }