@fabrika/app 0.3.2 → 0.4.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.
Files changed (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -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 +741 -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 +305 -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 +309 -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 +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -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 +808 -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 +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -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 +749 -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 +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -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,73 @@
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 { bootTestKit, type TestKit, type TestKitOptions } from './kit'
7
+ import type { SentInvitation, TestMembership } from './membership'
8
+
9
+ /** Someone the author clicks through the application as: a person the seed made with `dev.testCaller`. */
10
+ export interface DevPersona {
11
+ /** Shown on the picker's card and in the toolbar, unique among the personas: `Petra`. */
12
+ readonly name: string
13
+ /** One short line of who this is: `Staff of the design workspace`. */
14
+ readonly description?: string
15
+ readonly caller: PersonTestCaller
16
+ }
17
+
18
+ /** A page a link holder opens: a link the seed issued through the application's own endpoint. */
19
+ export interface DevLink {
20
+ readonly title: string
21
+ readonly description?: string
22
+ /** The URL the application answered, a path of its origin: `/s/#px_…`. */
23
+ readonly url: string
24
+ }
25
+
26
+ /** A scope value the seed made, with the name a person knows it by: `{ value: 'w1', label: 'Design team' }`. */
27
+ export interface DevScopeValue {
28
+ readonly value: string
29
+ readonly label: string
30
+ }
31
+
32
+ /** What a seed answers; every part is optional. */
33
+ export interface DevSeedResult<C extends AnyContract> {
34
+ readonly personas?: readonly DevPersona[]
35
+ readonly links?: readonly DevLink[]
36
+ /** The values of each dimension the seed made, which the picker offers for a membership role. */
37
+ readonly scopes?: { readonly [D in DimensionOf<C>]?: readonly DevScopeValue[] }
38
+ }
39
+
40
+ /**
41
+ * The default export of `dev/seed.ts`: it receives the application's test kit, the API its tests use, writes through the
42
+ * endpoints, and answers who and what the picker offers.
43
+ */
44
+ export type DevSeed<C extends AnyContract> = (dev: TestKit<C>) => Promise<DevSeedResult<C>> | DevSeedResult<C>
45
+
46
+ export interface DevKit<C extends AnyContract> {
47
+ readonly kit: TestKit<C>
48
+ /** The person `value` is when this kit made it; `undefined` for any other value. */
49
+ personOf(value: unknown): PersonTestCaller | undefined
50
+ /** The roles a caller of this kit holds now: its grants, with what the application assigned and revoked through IAM. */
51
+ membershipsOf(caller: TestCaller): readonly TestMembership[]
52
+ /** The invitation e-mails the stand-in IAM sent, in the order it first sent them. */
53
+ invitations(): readonly SentInvitation[]
54
+ }
55
+
56
+ const isPerson = (caller: TestCaller): caller is PersonTestCaller => caller.kind === 'human' && caller.email !== null
57
+
58
+ /** The test kit of `app` with its stand-in IAM, and what `fabrika app dev` shows of it. */
59
+ export function devKit<C extends AnyContract>(app: ContractImplementation<C>, options: Omit<TestKitOptions, 'membership'> = {}): DevKit<C> {
60
+ const { kit, standIn, callerOf } = bootTestKit(app, options)
61
+ if (standIn === null) {
62
+ throw new Error('a kit without its own membership transport stands in for IAM')
63
+ }
64
+ return Object.freeze({
65
+ kit,
66
+ personOf(value: unknown): PersonTestCaller | undefined {
67
+ const caller = callerOf(value)
68
+ return caller !== undefined && isPerson(caller) ? caller : undefined
69
+ },
70
+ membershipsOf: (caller: TestCaller) => standIn.membershipsOf(caller),
71
+ invitations: () => standIn.invitations(),
72
+ })
73
+ }