@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.
Files changed (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -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 { DeclaredFields, Endpoint, ExportKit, Group, GroupBuild, GroupInput, GroupKit, LinkGroupKit, ListOf, PendingIn } from './group'
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 imports. */
64
- export type KitOf<V, Ix, Imp extends KitImports> = V extends AppVocabulary<infer A, infer D, infer R, infer Dims, infer M> ? {
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, Sc, S, U, Is, Ix extends AnyDbIndex, M = never> =
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 scope: Sc; readonly timeout?: string; readonly retries?: number }
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, Sc extends string, D> extends CronJob<Acts, Sc> {
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(handler: (args: { readonly ctx: JobKitContext<K, Acts, D> }) => void | Promise<void>): KitCronJob<Acts, Sc, D>
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: 'github-sha256'
128
+ readonly scheme: WebhookScheme
110
129
  readonly boundTo: B
111
130
  readonly actions: Acts
112
131
  readonly input: I & NotLoose<I>
113
- readonly maxBytes?: number
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: (args: { readonly input: z.output<I>; readonly ctx: WebhookKitContext<K, B, Acts, D> }) => void | Promise<void>,
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 [RpcCaller, ...RpcCaller[]], GroupInput>
174
+ | Group<readonly RpcCaller[], GroupInput>
148
175
  | { readonly [name: string]: MountedGroups }
149
176
 
150
- type CallersOf<G> = G extends Group<infer C, GroupInput> ? C : { [K in keyof G]: CallersOf<G[K]> }[keyof G]
151
- type EndpointsOf<G> = G extends Group<readonly [RpcCaller, ...RpcCaller[]], infer T> ? T : { readonly [K in keyof G]: EndpointsOf<G[K]> }
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
- type Callers<G> = CallersOf<G> extends infer C extends readonly [RpcCaller, ...RpcCaller[]] ? C : never
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 [RpcCaller, ...RpcCaller[]], infer T> ? PendingIn<T, Path>
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 callers, in the same order'> : Path
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
- /** A form that appends each submission to its inbox `appendTo`, one column per input field, and answers a constant. */
207
- form<
208
- I extends InputSchema,
209
- const T extends string,
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
- /** Endpoints for `callers`, declared and handled together; `fabrika.contract.ts` mounts the group with `c.rpc`. */
246
- group<const C extends readonly [RpcCaller, ...RpcCaller[]], const T extends GroupBuild>(
247
- options: { readonly callers: C } & Owned<'kind' | 'endpoints'>,
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 cron job (UTC, five fields) holding `actions` in `scope`; the contract mounts it under `jobs`. */
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
- >(schedule: string, options: CronOptions<Acts, Sc, S, U, Is, K['index'], M>): CronBuilder<K, Acts[number], Sc, Declared<S, U, never, Is, M>>
259
- /** A GitHub webhook bound per value of `boundTo`; the contract mounts it by name. */
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
- >(path: Path, options: WebhookOptions<B, Acts, I, S, U, K['index']>): WebhookBuilder<K, B, Acts[number], I, Declared<S, U, never, never>>
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
- /** The contract: mounts by name and the `files`, `jobs`, `exports` and `csp` sections; `imports` come from the kit. */
294
- contract<const S extends Surface & Owned<'imports'> & FormJobs<S>>(build: (c: ContractKit<K>) => S): KitContract<K, S>
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
- /** Options that may be absent are no overload: the contract's type would promise an imports section it may not have. */
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?: { readonly imports: KitImports }): object {
318
- return runtimeApp(vocabulary, options?.imports, tables !== undefined)
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 crossOrigin?: true
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.crossOrigin === undefined ? {} : { crossOrigin: def.crossOrigin }),
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 [RpcCaller, ...RpcCaller[]]
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 scope: string
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: 'github-sha256'
430
+ readonly scheme: WebhookScheme
393
431
  readonly boundTo: string
394
432
  readonly actions: readonly string[]
395
433
  readonly input: Schema
396
- readonly maxBytes?: number
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' | 'form' | 'files'> {
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: readonly [RpcCaller, ...RpcCaller[]] },
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
- * `imports` is what `defineApp`'s options carry; the contract has an imports section exactly when they carry one.
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, imports: KitImports | undefined, tables: boolean): RuntimeApp {
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
- '~kind': 'group',
441
- callers: options.callers,
442
- endpoints: build(endpointKit()),
443
- unknownOptions: unknownOptions(options, 'rpc'),
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
- scope: options.scope,
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
- maxBytes: options.maxBytes,
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, form: shared.form, files: shared.files })
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
- const declared = imports === undefined ? surface : { ...surface, imports: kitImportsSection(importDeclarations(imports), surface) }
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
- /** `c.rpc(prefix, groups)`: the groups' endpoints under the prefix, nested by the names `groups` gives them. */
517
- function rpc(prefix: Path, groups: MountedGroupsValue): RpcMountDeclaration<Schema> {
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
- const [first] = mounted
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
- if (mounted.some((group) => !sameCallers(group.callers, first.callers))) {
524
- throw new ContractError([`c.rpc('${prefix}'): every group of one RPC mount names the same callers, in the same order`])
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: first.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
  }
@@ -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 webhook with its exact actions, a job with the link mount it declares it `issues`, and every consumer of
53
- * an export with the actions of the export rows it may call: what each workload key carries (ADR-0022 "Principal kinds").
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 jobs = Object.entries(artifact.jobs).map(([name, job]): AppWorkloadDef => {
57
- const issues = artifact.declared?.[`${JOB_SITE_PREFIX}${name}`]?.issues ?? null
58
- return { kind: 'job', name, actions: [...job.actions].sort(byCodeUnit), ...(issues === null ? {} : { issues }) }
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 [...jobs, ...webhooks, ...apps].sort((a, b) => byCodeUnit(`${a.kind}:${a.name}`, `${b.kind}:${b.name}`))
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. */