@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.
- package/README.md +78 -90
- package/package.json +77 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +370 -0
- package/skill/references/dev.md +90 -0
- package/skill/references/endpoint.md +640 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +746 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +308 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +331 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1932 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +444 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +161 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +824 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +120 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1095 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +760 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +281 -0
- package/src/react/index.ts +34 -0
- package/src/react/provider.tsx +179 -0
- package/src/react/store.ts +215 -0
- package/src/react/testing.ts +144 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/challenge.ts +10 -0
- package/src/testing/dev.ts +99 -0
- package/src/testing/endpoint-access.ts +809 -0
- package/src/testing/index.ts +36 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +585 -0
- package/src/testing/membership.ts +797 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- 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
|
+
}
|