@fabrika/app 0.6.0 → 0.8.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 +10 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +58 -36
- package/skill/references/dev.md +11 -9
- package/skill/references/endpoint.md +154 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +450 -255
- package/skill/references/spa.md +6 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +14 -8
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +221 -79
- package/src/client/index.ts +3 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +168 -14
- package/src/contract/build.ts +1282 -619
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +79 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +142 -60
- package/src/contract/define.ts +92 -121
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +210 -157
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +6 -5
- package/src/implement/capability.ts +78 -2
- package/src/implement/challenge.ts +203 -47
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +831 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +167 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/challenge.ts +35 -1
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +168 -128
- package/src/testing/kit.ts +90 -25
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- package/src/files/collections.ts +0 -848
package/src/contract/kit.ts
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// handlers beside their endpoints, in any module, type-check without a cycle. Browser-safe: it builds plain data and
|
|
4
4
|
// holds each handler beside its site (`handled.ts`), never calling one.
|
|
5
5
|
|
|
6
|
-
import type { JobMailer, RpcCaller } from '@fabrika/auth-core'
|
|
6
|
+
import type { JobMailer, RpcCaller, WebhookScheme } from '@fabrika/auth-core'
|
|
7
7
|
import type { AnyDbIndex, DbIndex, Tables, Unavailable, UnscopedDeclaration, UnscopedMode } from '@fabrika/db/tables'
|
|
8
8
|
import type { z } from 'zod/v4'
|
|
9
|
-
import type { Declared, JobKitContext, KitImports, KitTypes, NoTablesIndex, SqlMode, WebhookKitContext } from './context'
|
|
9
|
+
import type { Declared, JobKitContext, KickContext, KitImports, KitTypes, NoTablesIndex, SqlMode, TokensContext, WebhookKitContext } from './context'
|
|
10
10
|
import {
|
|
11
11
|
type AccessDeclaration,
|
|
12
12
|
type CronDeclaration,
|
|
@@ -26,19 +26,14 @@ import {
|
|
|
26
26
|
unknownOptions,
|
|
27
27
|
type WebhookMountDeclaration,
|
|
28
28
|
} from './declaration'
|
|
29
|
-
import type { FORM_CHALLENGE_FIELD } from './defaults'
|
|
30
29
|
import {
|
|
31
30
|
type AnyContract,
|
|
32
31
|
type Contract,
|
|
33
32
|
type CreatableDimension,
|
|
34
33
|
type CronJob,
|
|
35
34
|
type Export,
|
|
36
|
-
type FormChallengeOption,
|
|
37
|
-
type FormFileOption,
|
|
38
|
-
type FormJobs,
|
|
39
|
-
type FormMount,
|
|
40
|
-
type FormOptions,
|
|
41
35
|
type InputSchema,
|
|
36
|
+
type JobLimitsOption,
|
|
42
37
|
type Kit,
|
|
43
38
|
type LinkMount,
|
|
44
39
|
type NotLoose,
|
|
@@ -52,16 +47,33 @@ import {
|
|
|
52
47
|
surfaceKit,
|
|
53
48
|
type WebhookMount,
|
|
54
49
|
} from './define'
|
|
55
|
-
import type {
|
|
50
|
+
import type {
|
|
51
|
+
DeclaredFields,
|
|
52
|
+
EgressField,
|
|
53
|
+
Endpoint,
|
|
54
|
+
ExportKit,
|
|
55
|
+
Group,
|
|
56
|
+
GroupBuild,
|
|
57
|
+
GroupInput,
|
|
58
|
+
GroupKit,
|
|
59
|
+
KicksField,
|
|
60
|
+
LimitsOption,
|
|
61
|
+
LinkGroupKit,
|
|
62
|
+
ListOf,
|
|
63
|
+
PendingIn,
|
|
64
|
+
SignsField,
|
|
65
|
+
} from './group'
|
|
56
66
|
import { withHandler, withKitOrigin, withSnapshots } from './handled'
|
|
57
67
|
import { ContractError } from './problems'
|
|
68
|
+
import type { TokenPurposes } from './token'
|
|
58
69
|
import type { AnyAppVocabulary, AppVocabulary } from './vocabulary'
|
|
59
70
|
|
|
60
71
|
/** Fields a builder sets itself: an options object that carries one does not type-check. */
|
|
61
72
|
type Owned<K extends string> = { readonly [P in K]?: never }
|
|
62
73
|
|
|
63
|
-
/** The kit's types, computed once from the vocabulary, the tables' index and the
|
|
64
|
-
export type KitOf<V, Ix, Imp extends KitImports
|
|
74
|
+
/** The kit's types, computed once from the vocabulary, the tables' index, the imports and the token purposes. */
|
|
75
|
+
export type KitOf<V, Ix, Imp extends KitImports, Tk extends TokenPurposes = Record<never, never>> = V extends
|
|
76
|
+
AppVocabulary<infer A, infer D, infer R, infer Dims, infer M> ? {
|
|
65
77
|
readonly action: A
|
|
66
78
|
readonly dimension: D
|
|
67
79
|
readonly creatable: CreatableDimension<Dims>
|
|
@@ -71,14 +83,18 @@ export type KitOf<V, Ix, Imp extends KitImports> = V extends AppVocabulary<infer
|
|
|
71
83
|
readonly index: Ix
|
|
72
84
|
readonly tables: Ix extends { readonly tables: infer T } ? keyof T & string : never
|
|
73
85
|
readonly imports: Imp
|
|
86
|
+
readonly tokens: Tk
|
|
74
87
|
}
|
|
75
88
|
: never
|
|
76
89
|
|
|
77
|
-
export type CronOptions<Acts,
|
|
90
|
+
export type CronOptions<Acts, S, U, Is, Ix extends AnyDbIndex, M = never, Ks = never, Sg = never, Purposes extends string = never> =
|
|
78
91
|
& Owned<'kind' | 'schedule'>
|
|
79
|
-
& { readonly actions: Acts; readonly
|
|
92
|
+
& { readonly actions: Acts; readonly limits?: JobLimitsOption; readonly retries?: number }
|
|
80
93
|
& DeclaredFields<S, U, never, Is, Ix>
|
|
81
94
|
& MailerField<M>
|
|
95
|
+
& KicksField<Ks>
|
|
96
|
+
& SignsField<Sg, Purposes>
|
|
97
|
+
& EgressField
|
|
82
98
|
|
|
83
99
|
/** The mail transport a job reaches, inferred from its optional field and then required, like a declared field. */
|
|
84
100
|
type MailerField<M> =
|
|
@@ -89,7 +105,7 @@ type MailerField<M> =
|
|
|
89
105
|
& ([M] extends [never] ? unknown : { readonly mailer: M })
|
|
90
106
|
|
|
91
107
|
/** A cron job of the kit, handled. */
|
|
92
|
-
export interface KitCronJob<Acts extends string,
|
|
108
|
+
export interface KitCronJob<Acts extends string, D> extends CronJob<Acts> {
|
|
93
109
|
readonly declared: D
|
|
94
110
|
}
|
|
95
111
|
|
|
@@ -97,22 +113,29 @@ export interface KitCronJob<Acts extends string, Sc extends string, D> extends C
|
|
|
97
113
|
export interface CronBuilder<
|
|
98
114
|
K extends KitTypes,
|
|
99
115
|
Acts extends string,
|
|
100
|
-
Sc extends string,
|
|
101
116
|
D extends Declared<SqlMode, unknown, never, string, JobMailer>,
|
|
117
|
+
Ks = never,
|
|
118
|
+
Sg = never,
|
|
102
119
|
> {
|
|
103
|
-
handle(
|
|
120
|
+
handle(
|
|
121
|
+
handler: (args: { readonly ctx: JobKitContext<K, Acts, D> & KickContext<Ks> & TokensContext<K, Sg> }) => void | Promise<void>,
|
|
122
|
+
): KitCronJob<Acts, D>
|
|
104
123
|
}
|
|
105
124
|
|
|
106
|
-
export type WebhookOptions<B, Acts, I, S, U, Ix extends AnyDbIndex> =
|
|
125
|
+
export type WebhookOptions<B, Acts, I, S, U, Ix extends AnyDbIndex, Ks = never, Sg = never, Purposes extends string = never> =
|
|
107
126
|
& Owned<'kind' | 'path'>
|
|
108
127
|
& {
|
|
109
|
-
readonly scheme:
|
|
128
|
+
readonly scheme: WebhookScheme
|
|
110
129
|
readonly boundTo: B
|
|
111
130
|
readonly actions: Acts
|
|
112
131
|
readonly input: I & NotLoose<I>
|
|
113
|
-
|
|
132
|
+
/** A delivery's body (1 MiB by default and at most) and its handler's time (10 s by default, at most 60 s). */
|
|
133
|
+
readonly limits?: LimitsOption
|
|
114
134
|
}
|
|
115
135
|
& DeclaredFields<S, U, never, never, Ix>
|
|
136
|
+
& KicksField<Ks>
|
|
137
|
+
& SignsField<Sg, Purposes>
|
|
138
|
+
& EgressField
|
|
116
139
|
|
|
117
140
|
/** A webhook of the kit, handled. */
|
|
118
141
|
export interface KitWebhook<B extends string, Acts extends string, I extends Schema, D> extends WebhookMount<B, Acts, I> {
|
|
@@ -126,9 +149,13 @@ export interface WebhookBuilder<
|
|
|
126
149
|
Acts extends string,
|
|
127
150
|
I extends InputSchema,
|
|
128
151
|
D extends Declared<SqlMode, unknown, never, never>,
|
|
152
|
+
Ks = never,
|
|
153
|
+
Sg = never,
|
|
129
154
|
> {
|
|
130
155
|
handle(
|
|
131
|
-
handler: (
|
|
156
|
+
handler: (
|
|
157
|
+
args: { readonly input: z.output<I>; readonly ctx: WebhookKitContext<K, B, Acts, D> & KickContext<Ks> & TokensContext<K, Sg> },
|
|
158
|
+
) => void | Promise<void>,
|
|
132
159
|
): KitWebhook<B, Acts, I, D>
|
|
133
160
|
}
|
|
134
161
|
|
|
@@ -144,18 +171,25 @@ export interface ExportTree {
|
|
|
144
171
|
|
|
145
172
|
/** What `c.rpc` mounts: one group, or groups by name, nested. */
|
|
146
173
|
export type MountedGroups =
|
|
147
|
-
| Group<readonly
|
|
174
|
+
| Group<readonly RpcCaller[], GroupInput>
|
|
148
175
|
| { readonly [name: string]: MountedGroups }
|
|
149
176
|
|
|
150
|
-
|
|
151
|
-
type
|
|
177
|
+
/** The callers each group names; a group of public procedures names none and is left out. */
|
|
178
|
+
type CallersOf<G> = G extends Group<infer C, GroupInput> ? (C extends readonly [] ? never : C) : { [K in keyof G]: CallersOf<G[K]> }[keyof G]
|
|
179
|
+
type EndpointsOf<G> = G extends Group<readonly RpcCaller[], infer T> ? T : { readonly [K in keyof G]: EndpointsOf<G[K]> }
|
|
180
|
+
|
|
181
|
+
/** A group that names callers names at least one; a group of public procedures leaves `callers` out. */
|
|
182
|
+
type SomeCallers<C> = C extends readonly []
|
|
183
|
+
? Unavailable<"callers names at least one caller: callers: ['human']; a group of public procedures names none">
|
|
184
|
+
: unknown
|
|
152
185
|
|
|
153
|
-
|
|
186
|
+
/** The callers of the mount's credentialed procedures, `readonly []` when every group is public. */
|
|
187
|
+
type Callers<G> = [CallersOf<G>] extends [never] ? readonly [] : CallersOf<G> extends infer C extends readonly RpcCaller[] ? C : never
|
|
154
188
|
type Endpoints<G> = EndpointsOf<G> extends infer T extends ProcedureTree ? T : never
|
|
155
189
|
|
|
156
190
|
type IsUnion<T, All = T> = T extends unknown ? ([All] extends [T] ? false : true) : never
|
|
157
191
|
|
|
158
|
-
type PendingOf<G, Path extends string = ''> = G extends Group<readonly
|
|
192
|
+
type PendingOf<G, Path extends string = ''> = G extends Group<readonly RpcCaller[], infer T> ? PendingIn<T, Path>
|
|
159
193
|
: { [K in keyof G & string]: PendingOf<G[K], `${Path}${K}.`> }[keyof G & string]
|
|
160
194
|
|
|
161
195
|
/**
|
|
@@ -163,107 +197,83 @@ type PendingOf<G, Path extends string = ''> = G extends Group<readonly [RpcCalle
|
|
|
163
197
|
* one without, because its own callback cannot be checked before it is typed), and every group names the same callers.
|
|
164
198
|
*/
|
|
165
199
|
type MountPrefix<G> = [PendingOf<G>] extends [never]
|
|
166
|
-
? true extends IsUnion<CallersOf<G>> ? Unavailable<'every group of one RPC mount names the same
|
|
200
|
+
? true extends IsUnion<CallersOf<G>> ? Unavailable<'every group of one RPC mount that names callers names the same ones, in the same order'>
|
|
201
|
+
: Path
|
|
167
202
|
: Unavailable<`add .handle(…) to ${ListOf<PendingOf<G>>}: every endpoint takes its handler beside it`>
|
|
168
203
|
|
|
169
|
-
/** The inbox tables of the kit: each a form's own table (`scope: { inbox, read }`). */
|
|
170
|
-
type InboxTables<Ix extends AnyDbIndex> = {
|
|
171
|
-
[T in keyof Ix['tables'] & string]: Ix['tables'][T]['mode'] extends 'inbox' ? T : never
|
|
172
|
-
}[keyof Ix['tables'] & string]
|
|
173
|
-
|
|
174
|
-
/** `unknown` for one literal name; a widened or union name would let the check below pass for any other. */
|
|
175
|
-
type OneName<N, Message extends string> = string extends N ? Unavailable<Message> : true extends IsUnion<N> ? Unavailable<Message> : unknown
|
|
176
|
-
|
|
177
|
-
type LiteralInbox = "appendTo names the form's inbox table as a literal: appendTo: '<inbox table>'"
|
|
178
|
-
type LiteralHoneypot = "honeypot names the field the page hides as a literal: honeypot: '<field>'"
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* `appendTo` names one inbox table, and the form's input fields are its columns, the honeypot `H` and the challenge
|
|
182
|
-
* token `Ch` exempt: the form writes each field to the column of its name. A widened honeypot is refused here too,
|
|
183
|
-
* since it would exempt every field.
|
|
184
|
-
*/
|
|
185
|
-
type InboxTarget<Ix extends AnyDbIndex, T, Fields, H, Ch> = unknown extends OneName<T, LiteralInbox>
|
|
186
|
-
? T extends InboxTables<Ix> & string
|
|
187
|
-
? unknown extends OneName<H, LiteralHoneypot>
|
|
188
|
-
? ExtraFields<T, Exclude<Fields & string, keyof Ix['tables'][T]['row'] | H | Ch>, keyof Ix['tables'][T]['row'] & string>
|
|
189
|
-
: OneName<H, LiteralHoneypot>
|
|
190
|
-
: [InboxTables<Ix>] extends [never]
|
|
191
|
-
? Unavailable<"no table of src/db/tables.ts is a form's inbox: declare one with scope: { inbox: '<form>', read }">
|
|
192
|
-
: Unavailable<`${T & string} is not an inbox table of src/db/tables.ts (inboxes: ${ListOf<InboxTables<Ix> & string>})`>
|
|
193
|
-
: OneName<T, LiteralInbox>
|
|
194
|
-
|
|
195
|
-
type ExtraFields<T extends string, Extra extends string, Columns extends string> = [Extra] extends [never] ? unknown
|
|
196
|
-
: Unavailable<`the inbox ${T} has no column ${ListOf<Extra>}: each input and file field is named like its column (columns: ${ListOf<Columns>})`>
|
|
197
|
-
|
|
198
|
-
/** The challenge token's field, exempt from the inbox's columns where the form declares a challenge. */
|
|
199
|
-
type ChallengeField<Ch> = [Ch] extends [never] ? never : typeof FORM_CHALLENGE_FIELD
|
|
200
|
-
|
|
201
|
-
/** A form's file fields: each is a column of its inbox too, which receives the held file's id. */
|
|
202
|
-
type FileFields<F> = [F] extends [never] ? never : keyof F
|
|
203
|
-
|
|
204
204
|
/** The builders of the contract's callback: the mounts that take no handler, and `rpc`, which mounts groups. */
|
|
205
205
|
export interface ContractKit<K extends KitTypes> extends Pick<Kit<K['action'], K['dimension'], K['creatable']>, 'spa' | 'health' | 'files'> {
|
|
206
|
-
/**
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const H extends string = never,
|
|
211
|
-
const Ch extends FormChallengeOption = never,
|
|
212
|
-
const Then extends string = never,
|
|
213
|
-
const F extends { readonly [field: string]: FormFileOption } = never,
|
|
214
|
-
>(
|
|
215
|
-
path: Path,
|
|
216
|
-
options: FormOptions<I, Then> & {
|
|
217
|
-
readonly appendTo: T & NoInfer<InboxTarget<K['index'], T, keyof z.output<I> | FileFields<F>, H, ChallengeField<Ch>>>
|
|
218
|
-
readonly honeypot?: H & NoInfer<OneName<H, LiteralHoneypot>>
|
|
219
|
-
readonly challenge?: Ch
|
|
220
|
-
readonly files?: F
|
|
221
|
-
},
|
|
222
|
-
): FormMount<I, NoInfer<Then>>
|
|
223
|
-
/** The groups' endpoints at `POST <prefix>/<group>.<endpoint>`, to the callers the groups name. */
|
|
206
|
+
/**
|
|
207
|
+
* The groups' endpoints at `POST <prefix>/<group>.<endpoint>`, to the callers the groups name. `compatibility:
|
|
208
|
+
* 'backward'` promises their callers that a later deploy breaks none of them; Control refuses one that does.
|
|
209
|
+
*/
|
|
224
210
|
rpc<const G extends MountedGroups>(
|
|
225
211
|
prefix: MountPrefix<G>,
|
|
226
212
|
groups: G,
|
|
213
|
+
options?: RpcMountOptions,
|
|
227
214
|
): RpcMount<Callers<G>, Endpoints<G>>
|
|
228
215
|
}
|
|
229
216
|
|
|
217
|
+
/** The options of `c.rpc`: the promise the mount makes its callers, `'unchecked'` by default. */
|
|
218
|
+
export interface RpcMountOptions {
|
|
219
|
+
readonly compatibility?: 'backward' | 'unchecked'
|
|
220
|
+
}
|
|
221
|
+
|
|
230
222
|
/** The `imports` section the contract carries: the kit's imports, with `usedBy` from the endpoints' `connect`. */
|
|
231
223
|
type ImportsSection<K extends KitTypes> = [keyof K['imports']] extends [never] ? unknown
|
|
232
224
|
: { readonly imports: { readonly [N in keyof K['imports']]: ImportDeclaration } }
|
|
233
225
|
|
|
226
|
+
/** The `tokens` section the contract carries: the kit's token purposes, as `defineApp`'s options declare them. */
|
|
227
|
+
type TokensSection<K extends KitTypes> = [keyof K['tokens']] extends [never] ? unknown : { readonly tokens: K['tokens'] }
|
|
228
|
+
|
|
234
229
|
/** The contract `app.contract` returns: the same `Contract` the client, the compiler and the test kit read. */
|
|
235
230
|
export type KitContract<K extends KitTypes, S extends Surface> = Contract<
|
|
236
231
|
K['action'],
|
|
237
232
|
K['dimension'],
|
|
238
233
|
K['role'],
|
|
239
234
|
K['dimensions'],
|
|
240
|
-
S & ImportsSection<K>
|
|
235
|
+
S & ImportsSection<K> & TokensSection<K>
|
|
241
236
|
>
|
|
242
237
|
|
|
243
238
|
/** The kit of one application: every builder is typed by its vocabulary, its tables and its imports. */
|
|
244
239
|
export interface App<K extends KitTypes> {
|
|
245
|
-
/**
|
|
246
|
-
|
|
247
|
-
|
|
240
|
+
/**
|
|
241
|
+
* Endpoints for `callers`, declared and handled together; `fabrika.contract.ts` mounts the group with `c.rpc`. A group
|
|
242
|
+
* of public procedures only (`access: { public: true }`), which anyone calls, names no callers: `app.group({}, …)`.
|
|
243
|
+
*/
|
|
244
|
+
group<const C extends readonly RpcCaller[] = readonly [], const T extends GroupBuild = GroupBuild>(
|
|
245
|
+
options: { readonly callers?: C & NoInfer<SomeCallers<C>> } & Owned<'kind' | 'endpoints'>,
|
|
248
246
|
build: (r: GroupKit<K, C[number]>) => T,
|
|
249
247
|
): Group<C, T>
|
|
250
|
-
/** A
|
|
248
|
+
/** A job of the whole application (UTC, five fields) holding `actions`; the contract mounts it under `jobs`. */
|
|
251
249
|
cron<
|
|
252
250
|
const Acts extends readonly [K['action'], ...K['action'][]],
|
|
253
|
-
const Sc extends K['dimension'] | 'app',
|
|
254
251
|
const S extends SqlMode = never,
|
|
255
252
|
const U extends UnscopedDeclaration<K['tables'], UnscopedMode> = never,
|
|
256
253
|
const Is extends string = never,
|
|
257
254
|
const M extends JobMailer = never,
|
|
258
|
-
|
|
259
|
-
|
|
255
|
+
const Ks extends readonly string[] = never,
|
|
256
|
+
const Sg extends readonly string[] = never,
|
|
257
|
+
>(
|
|
258
|
+
schedule: string,
|
|
259
|
+
options: CronOptions<Acts, S, U, Is, K['index'], M, Ks, Sg, keyof K['tokens'] & string>,
|
|
260
|
+
): CronBuilder<K, Acts[number], Declared<S, U, never, Is, M>, Ks, Sg>
|
|
261
|
+
/**
|
|
262
|
+
* A webhook whose deliveries `scheme` verifies, bound per value of `boundTo` or once for the application (`'app'`);
|
|
263
|
+
* the contract mounts it by name.
|
|
264
|
+
*/
|
|
260
265
|
webhook<
|
|
261
|
-
const B extends K['dimension'],
|
|
266
|
+
const B extends K['dimension'] | 'app',
|
|
262
267
|
const Acts extends readonly [K['action'], ...K['action'][]],
|
|
263
268
|
I extends InputSchema,
|
|
264
269
|
const S extends SqlMode = never,
|
|
265
270
|
const U extends UnscopedDeclaration<K['tables'], UnscopedMode> = never,
|
|
266
|
-
|
|
271
|
+
const Ks extends readonly string[] = never,
|
|
272
|
+
const Sg extends readonly string[] = never,
|
|
273
|
+
>(
|
|
274
|
+
path: Path,
|
|
275
|
+
options: WebhookOptions<B, Acts, I, S, U, K['index'], Ks, Sg, keyof K['tokens'] & string>,
|
|
276
|
+
): WebhookBuilder<K, B, Acts[number], I, Declared<S, U, never, never>, Ks, Sg>
|
|
267
277
|
/** A share SPA under `prefix` and its procedures under `<prefix>/api`, for an anonymous key bound to one object. */
|
|
268
278
|
link<
|
|
269
279
|
const LD extends K['dimension'],
|
|
@@ -290,8 +300,11 @@ export interface App<K extends KitTypes> {
|
|
|
290
300
|
readonly procedures: (e: ExportKit<K>) => T
|
|
291
301
|
} & Owned<'kind'>,
|
|
292
302
|
): Export<T>
|
|
293
|
-
/**
|
|
294
|
-
|
|
303
|
+
/**
|
|
304
|
+
* The contract: mounts by name and the `files`, `jobs`, `exports` and `csp` sections; `imports` and `tokens` come from
|
|
305
|
+
* the kit.
|
|
306
|
+
*/
|
|
307
|
+
contract<const S extends Surface & Owned<'imports' | 'tokens'>>(build: (c: ContractKit<K>) => S): KitContract<K, S>
|
|
295
308
|
}
|
|
296
309
|
|
|
297
310
|
/**
|
|
@@ -308,14 +321,34 @@ export function defineApp<V extends AnyAppVocabulary, DB, P>(
|
|
|
308
321
|
vocabulary: V,
|
|
309
322
|
tables: Tables<DB, P>,
|
|
310
323
|
): App<KitOf<V, DbIndex<DB, P, V>, Record<never, never>>>
|
|
311
|
-
/**
|
|
324
|
+
/**
|
|
325
|
+
* Options that may be absent are no overload, and neither is an option field that may be absent: the contract's type
|
|
326
|
+
* would promise an imports section, or token purposes, it may not have. Each overload requires the fields it infers.
|
|
327
|
+
*/
|
|
328
|
+
export function defineApp<V extends AnyAppVocabulary, DB, P, const Imp extends KitImports, const Tk extends TokenPurposes>(
|
|
329
|
+
vocabulary: V,
|
|
330
|
+
tables: Tables<DB, P>,
|
|
331
|
+
options: { readonly imports: Imp; readonly tokens: Tk },
|
|
332
|
+
): App<KitOf<V, DbIndex<DB, P, V>, Imp, Tk>>
|
|
333
|
+
export function defineApp<V extends AnyAppVocabulary, DB, P, const Tk extends TokenPurposes>(
|
|
334
|
+
vocabulary: V,
|
|
335
|
+
tables: Tables<DB, P>,
|
|
336
|
+
options: { readonly tokens: Tk },
|
|
337
|
+
): App<KitOf<V, DbIndex<DB, P, V>, Record<never, never>, Tk>>
|
|
312
338
|
export function defineApp<V extends AnyAppVocabulary, DB, P, const Imp extends KitImports>(
|
|
313
339
|
vocabulary: V,
|
|
314
340
|
tables: Tables<DB, P>,
|
|
315
341
|
options: { readonly imports: Imp },
|
|
316
342
|
): App<KitOf<V, DbIndex<DB, P, V>, Imp>>
|
|
317
|
-
export function defineApp(vocabulary: AnyAppVocabulary, tables?: unknown, options?:
|
|
318
|
-
return runtimeApp(vocabulary, options
|
|
343
|
+
export function defineApp(vocabulary: AnyAppVocabulary, tables?: unknown, options?: KitOptions): object {
|
|
344
|
+
return runtimeApp(vocabulary, options, tables !== undefined)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** What `defineApp`'s options carry: the connectors the kit imports, and the purposes of the tokens it signs. */
|
|
348
|
+
export interface KitOptions {
|
|
349
|
+
readonly imports?: KitImports
|
|
350
|
+
/** `{ <purpose>: token(Claims, { maxDays }) }`: each kind of signed token, declared once. */
|
|
351
|
+
readonly tokens?: TokenPurposes
|
|
319
352
|
}
|
|
320
353
|
|
|
321
354
|
// ── The runtime ─────────────────────────────────────────────────────────────────────────────────
|
|
@@ -342,7 +375,10 @@ interface EndpointOptions<Access> extends DeclaredDeclaration {
|
|
|
342
375
|
readonly input?: Schema | null
|
|
343
376
|
readonly output: Schema
|
|
344
377
|
readonly access: Access
|
|
345
|
-
readonly
|
|
378
|
+
readonly actions?: readonly string[]
|
|
379
|
+
readonly admission?: object
|
|
380
|
+
readonly limits?: object
|
|
381
|
+
readonly http?: object
|
|
346
382
|
}
|
|
347
383
|
|
|
348
384
|
function endpoint<Access>(type: ProcedureType, def: EndpointOptions<Access>): PendingSite<ProcedureDeclaration<Schema, Access>> {
|
|
@@ -352,7 +388,10 @@ function endpoint<Access>(type: ProcedureType, def: EndpointOptions<Access>): Pe
|
|
|
352
388
|
input: def.input ?? null,
|
|
353
389
|
output: def.output,
|
|
354
390
|
access: def.access,
|
|
355
|
-
...(def.
|
|
391
|
+
...(def.actions === undefined ? {} : { actions: def.actions }),
|
|
392
|
+
...(def.admission === undefined ? {} : { admission: def.admission }),
|
|
393
|
+
...(def.limits === undefined ? {} : { limits: def.limits }),
|
|
394
|
+
...(def.http === undefined ? {} : { http: def.http }),
|
|
356
395
|
declared: declaredOf(def),
|
|
357
396
|
unknownOptions: kitUnknownOptions(def, 'procedure'),
|
|
358
397
|
}
|
|
@@ -369,10 +408,10 @@ const endpointKit = <Access>(): RuntimeEndpointKit<Access> => ({
|
|
|
369
408
|
mutation: (def) => endpoint('mutation', def),
|
|
370
409
|
})
|
|
371
410
|
|
|
372
|
-
/** A group as data: its callers and its endpoints, which a mount nests by name. */
|
|
411
|
+
/** A group as data: its callers and its endpoints, which a mount nests by name. A group of public procedures has none. */
|
|
373
412
|
interface GroupDeclaration {
|
|
374
413
|
readonly '~kind': 'group'
|
|
375
|
-
readonly callers: readonly
|
|
414
|
+
readonly callers: readonly RpcCaller[]
|
|
376
415
|
readonly endpoints: ProcedureTreeDeclaration<Schema>
|
|
377
416
|
}
|
|
378
417
|
|
|
@@ -383,17 +422,16 @@ interface RuntimeGroup extends GroupDeclaration {
|
|
|
383
422
|
|
|
384
423
|
interface CronSiteOptions extends DeclaredDeclaration {
|
|
385
424
|
readonly actions: readonly string[]
|
|
386
|
-
readonly
|
|
387
|
-
readonly timeout?: string
|
|
425
|
+
readonly limits?: object
|
|
388
426
|
readonly retries?: number
|
|
389
427
|
}
|
|
390
428
|
|
|
391
429
|
interface WebhookSiteOptions extends DeclaredDeclaration {
|
|
392
|
-
readonly scheme:
|
|
430
|
+
readonly scheme: WebhookScheme
|
|
393
431
|
readonly boundTo: string
|
|
394
432
|
readonly actions: readonly string[]
|
|
395
433
|
readonly input: Schema
|
|
396
|
-
readonly
|
|
434
|
+
readonly limits?: object
|
|
397
435
|
}
|
|
398
436
|
|
|
399
437
|
interface LinkSiteOptions {
|
|
@@ -414,13 +452,13 @@ interface ExportSiteOptions {
|
|
|
414
452
|
|
|
415
453
|
type MountedGroupsValue = GroupDeclaration | { readonly [name: string]: MountedGroupsValue }
|
|
416
454
|
|
|
417
|
-
interface RuntimeContractKit extends Pick<Kit<string, string, string>, 'spa' | 'health' | '
|
|
418
|
-
rpc(prefix: Path, groups: MountedGroupsValue): RpcMountDeclaration<Schema>
|
|
455
|
+
interface RuntimeContractKit extends Pick<Kit<string, string, string>, 'spa' | 'health' | 'files'> {
|
|
456
|
+
rpc(prefix: Path, groups: MountedGroupsValue, options?: object): RpcMountDeclaration<Schema>
|
|
419
457
|
}
|
|
420
458
|
|
|
421
459
|
interface RuntimeApp {
|
|
422
460
|
group(
|
|
423
|
-
options: { readonly callers
|
|
461
|
+
options: { readonly callers?: readonly RpcCaller[] },
|
|
424
462
|
build: (r: RuntimeEndpointKit<AccessDeclaration>) => ProcedureTreeDeclaration<Schema>,
|
|
425
463
|
): RuntimeGroup
|
|
426
464
|
cron(schedule: string, options: CronSiteOptions): PendingSite<CronDeclaration>
|
|
@@ -431,24 +469,34 @@ interface RuntimeApp {
|
|
|
431
469
|
}
|
|
432
470
|
|
|
433
471
|
/**
|
|
434
|
-
* `
|
|
435
|
-
* `tables` is whether the kit was given tables: a contract of a kit without them is served without a database.
|
|
472
|
+
* `options` is what `defineApp`'s options carry; the contract has an imports or a tokens section exactly when they carry
|
|
473
|
+
* one. `tables` is whether the kit was given tables: a contract of a kit without them is served without a database.
|
|
436
474
|
*/
|
|
437
|
-
function runtimeApp(vocabulary: AnyAppVocabulary,
|
|
475
|
+
function runtimeApp(vocabulary: AnyAppVocabulary, options: KitOptions | undefined, tables: boolean): RuntimeApp {
|
|
476
|
+
const imports = options?.imports
|
|
477
|
+
const tokens = options?.tokens
|
|
438
478
|
return {
|
|
439
|
-
group: (options, build) =>
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
479
|
+
group: (options, build) => {
|
|
480
|
+
const callers = options.callers ?? []
|
|
481
|
+
const endpoints = build(endpointKit())
|
|
482
|
+
if (callers.length === 0) {
|
|
483
|
+
const credentialed = procedureEntries(endpoints).filter(([, procedure]) => !isPublicAccess(procedure.access)).map(([path]) => path)
|
|
484
|
+
if (credentialed.length > 0) {
|
|
485
|
+
throw new ContractError([
|
|
486
|
+
`app.group: ${credentialed.join(', ')} ${
|
|
487
|
+
credentialed.length === 1 ? 'is' : 'are'
|
|
488
|
+
} not public, and the group names no callers (allowed: app.group({ callers: ['human'] }, …), or access: { public: true })`,
|
|
489
|
+
])
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return { '~kind': 'group', callers, endpoints, unknownOptions: unknownOptions(options, 'rpc') }
|
|
493
|
+
},
|
|
445
494
|
cron: (schedule, options) => {
|
|
446
495
|
const declaration: CronDeclaration = {
|
|
447
496
|
kind: 'cron',
|
|
448
497
|
schedule,
|
|
449
498
|
actions: options.actions,
|
|
450
|
-
|
|
451
|
-
timeout: options.timeout,
|
|
499
|
+
...(options.limits === undefined ? {} : { limits: options.limits }),
|
|
452
500
|
retries: options.retries,
|
|
453
501
|
declared: declaredOf(options),
|
|
454
502
|
unknownOptions: kitUnknownOptions(options, 'cron'),
|
|
@@ -463,7 +511,7 @@ function runtimeApp(vocabulary: AnyAppVocabulary, imports: KitImports | undefine
|
|
|
463
511
|
boundTo: options.boundTo,
|
|
464
512
|
actions: options.actions,
|
|
465
513
|
input: options.input,
|
|
466
|
-
|
|
514
|
+
...(options.limits === undefined ? {} : { limits: options.limits }),
|
|
467
515
|
declared: declaredOf(options),
|
|
468
516
|
unknownOptions: kitUnknownOptions(options, 'webhook'),
|
|
469
517
|
}
|
|
@@ -490,11 +538,15 @@ function runtimeApp(vocabulary: AnyAppVocabulary, imports: KitImports | undefine
|
|
|
490
538
|
}),
|
|
491
539
|
contract: (build) => {
|
|
492
540
|
const shared = surfaceKit()
|
|
493
|
-
const surface = build({ rpc, spa: shared.spa, health: shared.health,
|
|
541
|
+
const surface = build({ rpc, spa: shared.spa, health: shared.health, files: shared.files })
|
|
494
542
|
if (surface.imports !== undefined) {
|
|
495
543
|
throw new ContractError(['contract: imports come from defineApp(vocabulary, tables, { imports }); remove the section'])
|
|
496
544
|
}
|
|
497
|
-
|
|
545
|
+
if (surface.tokens !== undefined) {
|
|
546
|
+
throw new ContractError(['contract: tokens come from defineApp(vocabulary, tables, { tokens }); remove the section'])
|
|
547
|
+
}
|
|
548
|
+
const withImports = imports === undefined ? surface : { ...surface, imports: kitImportsSection(importDeclarations(imports), surface) }
|
|
549
|
+
const declared = tokens === undefined ? withImports : { ...withImports, tokens }
|
|
498
550
|
const contract: AnyContract = {
|
|
499
551
|
vocabulary: {
|
|
500
552
|
app: vocabulary.app,
|
|
@@ -513,25 +565,53 @@ function runtimeApp(vocabulary: AnyAppVocabulary, imports: KitImports | undefine
|
|
|
513
565
|
}
|
|
514
566
|
}
|
|
515
567
|
|
|
516
|
-
/**
|
|
517
|
-
|
|
568
|
+
/**
|
|
569
|
+
* `c.rpc(prefix, groups, options?)`: the groups' endpoints under the prefix, nested by the names `groups` gives them.
|
|
570
|
+
* `compatibility` is copied as written, and `buildArtifact` checks it.
|
|
571
|
+
*/
|
|
572
|
+
function rpc(prefix: Path, groups: MountedGroupsValue, options: object = {}): RpcMountDeclaration<Schema> {
|
|
518
573
|
const mounted = mountedGroups(groups)
|
|
519
|
-
|
|
520
|
-
if (first === undefined) {
|
|
574
|
+
if (mounted.length === 0) {
|
|
521
575
|
throw new ContractError([`c.rpc('${prefix}'): mounts no group (allowed: c.rpc('${prefix}', { <name>: <group> }))`])
|
|
522
576
|
}
|
|
523
|
-
|
|
524
|
-
|
|
577
|
+
// A group of public procedures names no callers; every group that names them names the same ones.
|
|
578
|
+
const credentialed = mounted.filter((group) => group.callers.length > 0)
|
|
579
|
+
const callers = credentialed[0]?.callers ?? []
|
|
580
|
+
if (credentialed.some((group) => !sameCallers(group.callers, callers))) {
|
|
581
|
+
throw new ContractError([`c.rpc('${prefix}'): every group of one RPC mount that names callers names the same ones, in the same order`])
|
|
525
582
|
}
|
|
526
583
|
return {
|
|
527
584
|
kind: 'rpc',
|
|
528
585
|
prefix,
|
|
529
|
-
callers
|
|
586
|
+
callers,
|
|
530
587
|
procedures: mountedProcedures(groups),
|
|
588
|
+
...rpcMountOptions(prefix, options),
|
|
531
589
|
unknownOptions: [...new Set(mounted.flatMap((group) => isRuntimeGroup(group) ? group.unknownOptions : []))],
|
|
532
590
|
}
|
|
533
591
|
}
|
|
534
592
|
|
|
593
|
+
/** The `compatibility` of `c.rpc`'s options, only when written; it takes no other option. */
|
|
594
|
+
function rpcMountOptions(prefix: Path, options: object): { readonly compatibility?: unknown } {
|
|
595
|
+
const unknown = Object.keys(options).filter((key) => key !== 'compatibility')
|
|
596
|
+
if (unknown.length > 0) {
|
|
597
|
+
throw new ContractError([`c.rpc('${prefix}'): ${unknown.map((key) => `'${key}'`).join(', ')} is not an option (allowed: compatibility)`])
|
|
598
|
+
}
|
|
599
|
+
const compatibility: unknown = Reflect.get(options, 'compatibility')
|
|
600
|
+
return compatibility === undefined ? {} : { compatibility }
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function isPublicAccess(access: unknown): boolean {
|
|
604
|
+
return typeof access === 'object' && access !== null && 'public' in access && access.public === true
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** Every procedure of a tree with its dotted path. */
|
|
608
|
+
function procedureEntries(tree: ProcedureTreeDeclaration<Schema>, prefix = ''): [string, ProcedureDeclaration<Schema>][] {
|
|
609
|
+
return Object.entries(tree).flatMap(([name, node]): [string, ProcedureDeclaration<Schema>][] => {
|
|
610
|
+
const path = prefix === '' ? name : `${prefix}.${name}`
|
|
611
|
+
return isProcedure(node) ? [[path, node]] : procedureEntries(node, path)
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
535
615
|
function isGroup(value: MountedGroupsValue): value is GroupDeclaration {
|
|
536
616
|
return value['~kind'] === 'group'
|
|
537
617
|
}
|
package/src/contract/platform.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type AppScopeDef,
|
|
10
10
|
type AppWorkloadDef,
|
|
11
11
|
type ContractArtifact,
|
|
12
|
+
contractWorkloads,
|
|
12
13
|
type DimensionArtifact,
|
|
13
14
|
directoryLookups,
|
|
14
15
|
endpointDelegation,
|
|
@@ -49,17 +50,15 @@ export function schemaFromContract(artifact: ContractArtifact): AppSchema {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/**
|
|
52
|
-
* Every job and
|
|
53
|
-
* an export with the actions of the export rows it may call: what each workload key carries (ADR-0022
|
|
53
|
+
* Every job, webhook and public procedure with its exact actions, a job with the link mount it declares it `issues`, and
|
|
54
|
+
* every consumer of an export with the actions of the export rows it may call: what each workload key carries (ADR-0022
|
|
55
|
+
* "Principal kinds").
|
|
54
56
|
*/
|
|
55
57
|
function workloadsOf(artifact: ContractArtifact): AppWorkloadDef[] {
|
|
56
|
-
const
|
|
57
|
-
const issues = artifact.declared?.[`${JOB_SITE_PREFIX}${name}`]?.issues ?? null
|
|
58
|
-
return { kind:
|
|
58
|
+
const own = contractWorkloads(artifact).map((workload): AppWorkloadDef => {
|
|
59
|
+
const issues = workload.kind === 'job' ? (artifact.declared?.[`${JOB_SITE_PREFIX}${workload.name}`]?.issues ?? null) : null
|
|
60
|
+
return { kind: workload.kind, name: workload.name, actions: [...workload.actions], ...(issues === null ? {} : { issues }) }
|
|
59
61
|
})
|
|
60
|
-
const webhooks = Object.entries(artifact.mounts).flatMap(([name, mount]): AppWorkloadDef[] =>
|
|
61
|
-
mount.kind === 'webhook' ? [{ kind: 'webhook', name, actions: [...mount.actions].sort(byCodeUnit) }] : []
|
|
62
|
-
)
|
|
63
62
|
const consumers = new Map<string, Set<string>>()
|
|
64
63
|
for (const row of artifact.endpoints) {
|
|
65
64
|
if (row.source.kind !== 'export' || row.requirement?.kind !== 'action') continue
|
|
@@ -70,7 +69,7 @@ function workloadsOf(artifact: ContractArtifact): AppWorkloadDef[] {
|
|
|
70
69
|
}
|
|
71
70
|
}
|
|
72
71
|
const apps = [...consumers].map(([name, actions]): AppWorkloadDef => ({ kind: 'app', name, actions: [...actions].sort(byCodeUnit) }))
|
|
73
|
-
return [...
|
|
72
|
+
return [...own, ...apps].sort((a, b) => byCodeUnit(`${a.kind}:${a.name}`, `${b.kind}:${b.name}`))
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
/** Key order differs between the two producers of an artifact; the schema must not. */
|