@fabrika/app 0.5.5 → 0.7.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 (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -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 +387 -236
  10. package/skill/references/spa.md +5 -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 +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  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 +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
@@ -2,7 +2,8 @@
2
2
  // `buildArtifact`. A reader of source produces the same declaration and calls `buildArtifact` itself.
3
3
 
4
4
  import type { ContractArtifact, SchemaShape, TablePolicyArtifact } from '@fabrika/auth-core'
5
- import { inboxColumns, isTables, tablePolicies, type Tables } from '@fabrika/db/tables'
5
+ import { isTables, tablePolicies, type Tables } from '@fabrika/db/tables'
6
+ import { z } from 'zod/v4'
6
7
  import { attachmentOf } from './attachment'
7
8
  import { buildArtifact } from './build'
8
9
  import {
@@ -13,24 +14,25 @@ import {
13
14
  type MountDeclaration,
14
15
  type ProcedureDeclaration,
15
16
  type ProcedureTreeDeclaration,
17
+ type RedirectDeclaration,
16
18
  SURFACE_SECTIONS,
17
19
  type SurfaceSection,
18
20
  } from './declaration'
19
21
  import type { AnyContract, AnyMount, Schema, Surface } from './define'
20
22
  import { Problems } from './problems'
23
+ import { redirectOf } from './redirect'
21
24
  import { describeSchema } from './schema-shape'
22
25
 
23
26
  /**
24
27
  * The normalized artifact of a contract and its table policies: what `defineTables` returned (`src/db/tables.ts`),
25
- * or the artifact form of each policy. Only the first knows each inbox's columns, so only it checks a form's fields
26
- * against them; `boot` checks them against the data layer either way.
28
+ * or the artifact form of each policy.
27
29
  */
28
30
  export function compileContract<DB, P>(
29
31
  contract: AnyContract,
30
32
  tables: Tables<DB, P> | Readonly<Record<string, TablePolicyArtifact>>,
31
33
  ): ContractArtifact {
32
34
  const described = describeContract(contract)
33
- return isTables(tables) ? buildArtifact(described, tablePolicies(tables), inboxColumns(tables)) : buildArtifact(described, tables)
35
+ return buildArtifact(described, isTables(tables) ? tablePolicies(tables) : tables)
34
36
  }
35
37
 
36
38
  /** The contract with its schemas read as shapes; throws `ContractError` for a schema outside the subset. */
@@ -46,13 +48,21 @@ export function describeContract(contract: AnyContract): ContractDeclaration<Sch
46
48
  if (isMount(entry)) {
47
49
  mounts[name] = reader.mount(name, entry)
48
50
  } else {
49
- problems.add(`mount '${name}'`, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health, c.form or c.webhook)')
51
+ problems.add(`mount '${name}'`, 'is not a mount (allowed: c.rpc, c.link, c.spa, c.health or app.webhook)')
50
52
  }
51
53
  }
52
54
  const exports: Record<string, ExportDeclaration<SchemaShape>> = {}
53
55
  for (const [name, declaration] of Object.entries(surface.exports ?? {})) {
54
56
  exports[name] = { ...declaration, procedures: reader.tree(`export '${name}'`, declaration.procedures) }
55
57
  }
58
+ const tokens = surface.tokens === undefined
59
+ ? undefined
60
+ : Object.fromEntries(
61
+ Object.entries(surface.tokens).map(([purpose, declared]) => [purpose, {
62
+ claims: reader.schema(`token '${purpose}' claims`, declared.claims),
63
+ maxDays: declared.maxDays,
64
+ }]),
65
+ )
56
66
  problems.throwIfAny()
57
67
  return {
58
68
  app: vocabulary.app,
@@ -65,6 +75,7 @@ export function describeContract(contract: AnyContract): ContractDeclaration<Sch
65
75
  exports,
66
76
  imports: surface.imports ?? {},
67
77
  csp: surface.csp ?? {},
78
+ ...(tokens === undefined ? {} : { tokens }),
68
79
  ...(vocabulary.locale === undefined ? {} : { locale: vocabulary.locale }),
69
80
  ...(vocabulary.displayName === undefined ? {} : { displayName: vocabulary.displayName }),
70
81
  ...(vocabulary.mail === undefined ? {} : { mail: vocabulary.mail }),
@@ -79,7 +90,7 @@ function isSection(name: string): name is SurfaceSection {
79
90
  return SECTIONS.has(name)
80
91
  }
81
92
 
82
- const MOUNT_KINDS: ReadonlySet<string> = new Set(['rpc', 'link', 'spa', 'health', 'form', 'webhook'])
93
+ const MOUNT_KINDS: ReadonlySet<string> = new Set(['rpc', 'link', 'spa', 'health', 'webhook'])
83
94
 
84
95
  function isMount(entry: Surface[string]): entry is AnyMount {
85
96
  return typeof entry === 'object' && 'kind' in entry && typeof entry.kind === 'string' && MOUNT_KINDS.has(entry.kind)
@@ -99,8 +110,6 @@ class SchemaReader {
99
110
  return { ...mount, procedures: this.tree(where, mount.procedures) }
100
111
  case 'link':
101
112
  return { ...mount, procedures: this.tree(where, mount.procedures) }
102
- case 'form':
103
- return { ...mount, input: this.schema(`${where} input`, mount.input) }
104
113
  case 'webhook':
105
114
  return { ...mount, input: this.schema(`${where} input`, mount.input) }
106
115
  default:
@@ -118,6 +127,7 @@ class SchemaReader {
118
127
  ...node,
119
128
  input: node.input === null ? null : this.schema(`${at} input`, node.input),
120
129
  ...this.output(`${at} output`, node.output),
130
+ ...(node.http === undefined ? {} : { http: this.http(`${at} http.query`, node.http) }),
121
131
  }
122
132
  } else {
123
133
  described[name] = this.tree(where, node, path)
@@ -126,8 +136,27 @@ class SchemaReader {
126
136
  return described
127
137
  }
128
138
 
129
- /** An `attachment()` output is described as the rows' array, its declaration carried beside it. */
130
- output(where: string, schema: Schema): { readonly output: SchemaShape; readonly attachment?: AttachmentDeclaration } {
139
+ /** `http` as written, its `query` schema described; `buildArtifact` checks the rest. */
140
+ http(where: string, http: unknown): unknown {
141
+ if (typeof http !== 'object' || http === null) {
142
+ return http
143
+ }
144
+ const query: unknown = Reflect.get(http, 'query')
145
+ return isSchema(query) ? { ...http, query: this.schema(where, query) } : http
146
+ }
147
+
148
+ /**
149
+ * An `attachment()` output is described as the rows' array, its declaration carried beside it; a `redirect()` as its
150
+ * `{ to }` schema, its origins beside it.
151
+ */
152
+ output(
153
+ where: string,
154
+ schema: Schema,
155
+ ): { readonly output: SchemaShape; readonly attachment?: AttachmentDeclaration; readonly redirect?: RedirectDeclaration } {
156
+ const target = redirectOf(schema)
157
+ if (target !== null) {
158
+ return { output: this.schema(where, schema), redirect: { origins: [...target.origins] } }
159
+ }
131
160
  const mark = attachmentOf(schema)
132
161
  if (mark === null) {
133
162
  return { output: this.schema(where, schema) }
@@ -141,3 +170,8 @@ class SchemaReader {
141
170
  return describeSchema(schema, where, this.#problems) ?? { type: 'null' }
142
171
  }
143
172
  }
173
+
174
+ /** Whether `value` is a zod schema: what a contract writes where a schema stands. */
175
+ function isSchema(value: unknown): value is Schema {
176
+ return value instanceof z.ZodType
177
+ }
@@ -3,13 +3,16 @@
3
3
  // Browser-safe: the snapshot is imported by the contract, which the SPA imports.
4
4
 
5
5
  import {
6
+ canonicalAdmission,
6
7
  canonicalJson,
8
+ canonicalOutput,
7
9
  canonicalShape,
8
10
  type ContractArtifact,
11
+ type EndpointRow,
9
12
  type ExportDescription,
10
13
  exportDigest,
11
14
  type ExportProcedureDescription,
12
- isAttachmentShape,
15
+ isSchemaOutput,
13
16
  type SchemaShape,
14
17
  } from '@fabrika/auth-core'
15
18
  import { z } from 'zod/v4'
@@ -49,10 +52,8 @@ export function exportDescription(artifact: ContractArtifact, name: string): Exp
49
52
  const prefix = `${exportBase(name, declaration.version)}.`
50
53
  const procedures: Record<string, ExportProcedureDescription> = {}
51
54
  for (const row of artifact.endpoints) {
52
- // The build refuses an attachment on an export, so an export row's output is a schema.
53
- if (
54
- row.source.kind === 'export' && row.source.name === name && row.id.startsWith(prefix) && row.output !== null && !isAttachmentShape(row.output)
55
- ) {
55
+ // The build refuses an attachment and a redirect on an export, so an export row's output is a schema.
56
+ if (row.source.kind === 'export' && row.source.name === name && row.id.startsWith(prefix) && row.output !== null && isSchemaOutput(row.output)) {
56
57
  procedures[row.id.slice(prefix.length)] = { input: row.input, output: row.output }
57
58
  }
58
59
  }
@@ -147,6 +148,71 @@ export function incompatibleChanges(before: ExportDescription, after: ExportDesc
147
148
  return problems
148
149
  }
149
150
 
151
+ /**
152
+ * What `after` breaks for the callers of each mount `before` promises `compatibility: 'backward'`: a procedure it
153
+ * removes, an input or a query that accepts less, another output, another HTTP shape (method, path, body codec), file
154
+ * answer or access (its kind, callers, requirement and cross-origin form), or a mount moved. The promise binds the
155
+ * publication that drops it too, so a breaking change is a new mount beside the old one. Empty when compatible.
156
+ */
157
+ export function incompatibleMountChanges(before: ContractArtifact, after: ContractArtifact): string[] {
158
+ const problems: string[] = []
159
+ for (const [name, mount] of Object.entries(before.mounts)) {
160
+ if (mount.kind !== 'rpc' || mount.compatibility !== 'backward') {
161
+ continue
162
+ }
163
+ const now = Object.hasOwn(after.mounts, name) ? after.mounts[name] : undefined
164
+ if (now?.kind !== 'rpc' || now.prefix !== mount.prefix) {
165
+ problems.push(`mount '${name}' promises backward compatibility, and this deploy removes or moves it`)
166
+ continue
167
+ }
168
+ for (const row of before.endpoints.filter((endpoint) => isMountProcedure(endpoint, name))) {
169
+ const next = after.endpoints.find((candidate) => candidate.id === row.id)
170
+ if (next === undefined || !isMountProcedure(next, name)) {
171
+ problems.push(`removes '${row.id}'`)
172
+ continue
173
+ }
174
+ problems.push(...procedureBreaks(row, next, before.procedures?.[row.id]?.query, after.procedures?.[row.id]?.query))
175
+ }
176
+ }
177
+ return problems
178
+ }
179
+
180
+ function isMountProcedure(row: EndpointRow, mount: string): boolean {
181
+ return row.source.kind === 'mount' && row.source.name === mount && (row.kind === 'query' || row.kind === 'mutation' || row.kind === 'public')
182
+ }
183
+
184
+ function procedureBreaks(
185
+ before: EndpointRow,
186
+ after: EndpointRow,
187
+ queryBefore: SchemaShape | undefined,
188
+ queryAfter: SchemaShape | undefined,
189
+ ): string[] {
190
+ const id = before.id
191
+ const breaks: string[] = []
192
+ if (!acceptsEveryInput(before.input, after.input) || !acceptsEveryInput(queryBefore ?? null, queryAfter ?? null)) {
193
+ breaks.push(`'${id}' accepts less input than before`)
194
+ }
195
+ if (
196
+ canonicalJson(before.output === null ? null : canonicalOutput(before.output))
197
+ !== canonicalJson(after.output === null ? null : canonicalOutput(after.output))
198
+ ) {
199
+ breaks.push(`'${id}' answers another output than before`)
200
+ }
201
+ const admission = (row: EndpointRow) => (row.admission === null ? null : canonicalAdmission(row.admission).contentTypes)
202
+ if (before.method !== after.method || before.path !== after.path || canonicalJson(admission(before)) !== canonicalJson(admission(after))) {
203
+ breaks.push(`'${id}' changes its HTTP shape`)
204
+ }
205
+ if (before.response !== after.response) {
206
+ breaks.push(`'${id}' changes its file answer`)
207
+ }
208
+ const access = (row: EndpointRow) =>
209
+ canonicalJson({ kind: row.kind, callers: [...row.callers].sort(), requirement: row.requirement, crossOrigin: row.crossOrigin ?? null })
210
+ if (access(before) !== access(after)) {
211
+ breaks.push(`'${id}' changes its access`)
212
+ }
213
+ return breaks
214
+ }
215
+
150
216
  function acceptsEveryInput(before: SchemaShape | null, after: SchemaShape | null): boolean {
151
217
  if (before === null || after === null) {
152
218
  return before === after
@@ -3,12 +3,15 @@
3
3
  // so a group in any module types itself without importing `fabrika.contract.ts`.
4
4
 
5
5
  import type { JobMailer, RpcCaller } from '@fabrika/auth-core'
6
+ import type { RowRef } from '@fabrika/db/capability'
6
7
  import type {
7
8
  AnyDbIndex,
8
9
  ApplicationTables,
10
+ ClaimedRowHandle,
9
11
  DataKind,
10
12
  FragmentTable,
11
13
  NoUnscoped,
14
+ PublicApplicationTables,
12
15
  ReferenceTables,
13
16
  ScopedApplicationTables,
14
17
  ScopeTarget,
@@ -18,16 +21,18 @@ import type {
18
21
  WithinTables,
19
22
  } from '@fabrika/db/tables'
20
23
  import type { z } from 'zod/v4'
21
- import type { FileCollectionHandle, FileCollectionJobHandle, FileCollectionReadHandle, FileCollections } from '../files/handle'
24
+ import type { StoredFilesHandle, StoredFilesReadHandle } from '../files/handle'
22
25
  import type { AppCapability, Ref, Within } from '../implement/capability'
23
26
  import type { ApplicationDirectory, LinkIssuing, MemberDirectory, MemberLookup, MemberManagement } from '../implement/membership'
24
- import type { AppPrincipal, PrincipalOf, ServicePrincipal } from '../implement/principal'
25
- import type { ConnectCall, RequestContext } from '../implement/types'
27
+ import type { AppPrincipal, PrincipalOf, PublicPrincipal, ServicePrincipal } from '../implement/principal'
28
+ import type { CapabilityOf, ConnectCall, RequestContext } from '../implement/types'
26
29
  import type { WebhookDelivery, WebhookPrincipal } from '../implement/webhook'
27
30
  import type { AppMail, JobContext } from '../jobs/types'
28
31
  import type { ConnectSnapshot } from './connect'
29
32
  import type { MemberAccess, Schema } from './define'
30
33
  import type { App } from './kit'
34
+ import type { TokenPurposes, TokenSigning } from './token'
35
+ import type { WebhookBindings } from './webhook-binding'
31
36
 
32
37
  export type { Unavailable } from '@fabrika/db/tables'
33
38
 
@@ -55,6 +60,8 @@ export interface KitTypes {
55
60
  /** The table names of `src/db/tables.ts`. */
56
61
  readonly tables: string
57
62
  readonly imports: KitImports
63
+ /** The token purposes of `defineApp`'s options, by purpose. */
64
+ readonly tokens: TokenPurposes
58
65
  }
59
66
 
60
67
  /**
@@ -134,17 +141,13 @@ type SqlContext<S, Ix extends AnyDbIndex> = {
134
141
  : SqlTag<Ix, S & SqlMode>
135
142
  }
136
143
 
137
- /** `ctx.files(<collection>)`: removal where the site writes (a mutation, a job's transaction), reading everywhere. */
144
+ /** `ctx.files.put` and `.open` of the stored files: a put everywhere but in a query. */
138
145
  type FilesContext<Ix, K extends DataKind | 'transaction'> = {
139
- readonly files: Ix extends NoTablesIndex ? Unavailable<'this application declares no tables, so it has no file collections'>
140
- : FileCollections<
141
- K extends 'transaction' ? FileCollectionJobHandle : K extends 'mutation' ? FileCollectionHandle : FileCollectionReadHandle<RemovesIn<K>>
142
- >
146
+ readonly files: Ix extends NoTablesIndex ? Unavailable<'this application declares no tables, so it stores no files'>
147
+ : K extends 'query' ? StoredFilesReadHandle
148
+ : StoredFilesHandle
143
149
  }
144
150
 
145
- type RemovesIn<K extends DataKind | 'transaction'> = K extends 'job' ? 'a job removes files inside ctx.transaction((tx) => …), as tx.files'
146
- : 'a query reads only; remove files in a mutation'
147
-
148
151
  type UnscopedData<Ix extends AnyDbIndex, U, K extends DataKind> = {
149
152
  readonly unscoped: [U] extends [never] ? NoUnscoped : UnscopedTables<Ix, U, K>
150
153
  }
@@ -162,6 +165,9 @@ type IsUnion<T, All = T> = T extends unknown ? ([All] extends [T] ? false : true
162
165
  */
163
166
  export type CheckedAccess<Acc> = true extends IsUnion<Acc> ? InvalidAccess : Acc
164
167
 
168
+ /** Every action several capabilities name. */
169
+ type CapabilityActions<C> = { [N in keyof C]: C[N] extends { readonly action: infer A extends string } ? A : never }[keyof C]
170
+
165
171
  /** The capability an access form hands, and what the other forms do not have. */
166
172
  export type CapabilityContext<Acc> = Acc extends InvalidAccess ? {
167
173
  readonly target: Unavailable<'the access form of this endpoint is refused where it is declared: fix it there'>
@@ -172,6 +178,11 @@ export type CapabilityContext<Acc> = Acc extends InvalidAccess ? {
172
178
  readonly target: Unavailable<'a member endpoint holds no capability: declare access with on or in for ctx.target'>
173
179
  readonly within: Unavailable<'a member endpoint holds no capability: declare access with over for ctx.within'>
174
180
  }
181
+ : Acc extends { readonly capabilities: infer C } ? {
182
+ readonly capabilities: { readonly [N in keyof C]: CapabilityOf<C[N]> }
183
+ readonly target: Unavailable<'an endpoint of several capabilities holds each in ctx.capabilities.<name>'>
184
+ readonly within: Unavailable<'an endpoint of several capabilities holds each in ctx.capabilities.<name>'>
185
+ }
175
186
  : Acc extends { readonly action: infer A extends string; readonly over: infer D extends string } ? {
176
187
  readonly within: Within<D, A>
177
188
  readonly target: Unavailable<'an over endpoint holds ctx.within, the scopes it may list in: declare access with on or in for ctx.target'>
@@ -203,7 +214,6 @@ type Reach<Ix extends AnyDbIndex, T extends string, Form extends 'target' | 'ove
203
214
  /** Why the handle `Where` holds no table `T`: one line that names the table, the access form, and the form that reaches it. */
204
215
  type Unreached<Ix extends AnyDbIndex, T extends string, Where extends string, Form extends 'target' | 'over'> = T extends Ix['references']
205
216
  ? Unavailable<`${T} is a reference table: read it through ctx.db.reference.${T}`>
206
- : TableOf<Ix, T>['mode'] extends 'inbox' ? Unavailable<`${T} is a form inbox, which no ctx.db handle reads`>
207
217
  : T extends Ix['applications']
208
218
  ? Unavailable<`${T} is an application table: read it through ctx.db.app.${T}, or declare access: { action, app: true }`>
209
219
  : Unavailable<`${T} is not under ${Where} (${Lies<Ix, T>}): ${Reach<Ix, T, Form>}`>
@@ -223,7 +233,12 @@ type Target<Ix extends AnyDbIndex, D extends string, A extends string, K extends
223
233
  'target'
224
234
  >
225
235
 
226
- type WithinData<Ix extends AnyDbIndex, D extends string, A extends string> = Reaching<Ix, WithinTables<Ix, D, A>, `over: '${D}'`, 'over'>
236
+ type WithinData<Ix extends AnyDbIndex, D extends string, A extends string, K extends DataKind> = Reaching<
237
+ Ix,
238
+ WithinTables<Ix, D, A, K>,
239
+ `over: '${D}'`,
240
+ 'over'
241
+ >
227
242
 
228
243
  type AppData<Ix extends AnyDbIndex, A extends string, K extends DataKind> = Reaching<Ix, ApplicationTables<Ix, A, K>, 'app: true', 'over'>
229
244
 
@@ -245,8 +260,15 @@ type AccessData<Ix extends AnyDbIndex, K extends DataKind, Acc> = Acc extends In
245
260
  : Acc extends MemberAccess ? {
246
261
  readonly target: Unavailable<'a member endpoint reaches no application data: declare access with on, in or over'>
247
262
  }
263
+ : Acc extends { readonly capabilities: infer C } ?
264
+ & CapabilityData<Ix, K, CapabilityActions<C>>
265
+ & {
266
+ readonly target: Unavailable<'an endpoint of several capabilities binds each: ctx.db.on(ctx.capabilities.<name>), .in or .over'>
267
+ readonly within: Unavailable<'an endpoint of several capabilities binds each: ctx.db.on(ctx.capabilities.<name>), .in or .over'>
268
+ readonly app: Unavailable<'an endpoint of several capabilities binds each: ctx.db.on(ctx.capabilities.<name>), .in or .over'>
269
+ }
248
270
  : Acc extends { readonly action: infer A extends string; readonly over: infer D extends string } ? {
249
- readonly within: WithinData<Ix, D, A>
271
+ readonly within: WithinData<Ix, D, A, K>
250
272
  readonly app: ScopedAppData<Ix, A>
251
273
  readonly target: Unavailable<'an over endpoint lists through ctx.db.within: declare access with on or in for ctx.db.target'>
252
274
  }
@@ -353,6 +375,63 @@ export type EndpointContext<
353
375
  & { readonly db: DataOf<K['index'], EndpointData<K['index'], Kind, Acc, Of<D, 'unscoped'>>> }
354
376
  & NoMail
355
377
 
378
+ type PublicOnly = 'a public procedure acts application-wide, as its own workload: ctx.app(<action>) and ctx.db.app(<action>)'
379
+
380
+ /**
381
+ * What the handler of a public procedure receives: anyone called it, and it runs as the endpoint's own workload, holding
382
+ * its declared actions `Acts` and nothing else. `ctx.app('<action>')` mints, and `ctx.db.app('<action>')` inserts and
383
+ * reads under it; no other reach.
384
+ */
385
+ export type PublicContext<K extends KitTypes, Kind extends DataKind, Acts extends string> =
386
+ & RequestContext<PublicPrincipal>
387
+ & {
388
+ /** A capability of one of the procedure's declared actions, application-wide, bound to this request. */
389
+ app<const A extends Acts>(action: A): AppCapability<A>
390
+ readonly target: Unavailable<PublicOnly>
391
+ readonly within: Unavailable<PublicOnly>
392
+ readonly members: Unavailable<'a public procedure reaches no member directory'>
393
+ readonly links: Unavailable<'a public procedure issues no share link'>
394
+ readonly connect: Unavailable<'a public procedure calls no other application'>
395
+ /** The stored files: a public mutation puts one, and both open one this request holds. */
396
+ readonly files: Kind extends 'query' ? StoredFilesReadHandle : StoredFilesHandle
397
+ readonly sql: Unavailable<'a public procedure reaches its actions only: it declares no sql'>
398
+ }
399
+ & {
400
+ readonly db: DataOf<
401
+ K['index'],
402
+ {
403
+ app<const A extends Acts>(action: A): PublicApplicationTables<K['index'], A, Kind>
404
+ readonly target: Unavailable<PublicOnly>
405
+ readonly within: Unavailable<PublicOnly>
406
+ readonly unscoped: Unavailable<'a public procedure reaches its actions only: it declares no unscoped'>
407
+ }
408
+ >
409
+ }
410
+ & NoMail
411
+
412
+ /**
413
+ * `ctx.kick('<job>')` of a site that declares `kicks`: the job runs in the background after the site's transaction
414
+ * committed, once however often it is kicked, and not at all on a rollback.
415
+ */
416
+ export type KickContext<Ks> = [Ks] extends [never]
417
+ ? { readonly kick: Unavailable<"declare kicks: ['<cron job>'] on this site to kick a job after its commit"> }
418
+ : Ks extends readonly (infer N extends string)[] ? { kick(job: N): void }
419
+ : never
420
+
421
+ /** `ctx.tokens.sign('<purpose>', claims, { days })` of a site that declares `signs`: a token of one of those purposes. */
422
+ export type TokensContext<K extends KitTypes, Sg> = [Sg] extends [never]
423
+ ? { readonly tokens: Unavailable<"declare signs: ['<token purpose>'] on this site to sign its tokens"> }
424
+ : Sg extends readonly (infer P extends string)[] ? { readonly tokens: TokenSigning<K['tokens'], P> }
425
+ : never
426
+
427
+ /**
428
+ * `ctx.webhooks('<webhook>')` of a mutation that declares `binds`: the webhook's bindings, on the value a capability of
429
+ * this request names, written in the mutation's transaction.
430
+ */
431
+ export type WebhooksContext<Bs> = [Bs] extends [never]
432
+ ? { readonly webhooks: Unavailable<"declare binds: '<webhook>' on this mutation to create and remove its bindings"> }
433
+ : { webhooks(name: Bs): WebhookBindings }
434
+
356
435
  /** A link procedure acts on the one object its key is bound to. */
357
436
  export type LinkContext<
358
437
  K extends KitTypes,
@@ -384,8 +463,10 @@ export type ExportContext<K extends KitTypes, Acc, D extends Declared<'read', un
384
463
  */
385
464
  export interface CapabilityData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string> {
386
465
  on<const D extends string, const A extends Acts>(ref: Ref<D, A>): Target<Ix, D, A, Kind, 'on'>
466
+ /** A row a claim leased (`row.ref`), finished inside `ctx.transaction`. */
467
+ on<const T extends TableName<Ix>, const A extends Acts>(ref: RowRef<T, A>): ClaimedRowHandle<T, Ix['tables'][T], A, Kind>
387
468
  in<const D extends string, const A extends Acts>(ref: Ref<D, A>): Target<Ix, D, A, Kind, 'in'>
388
- over<const D extends string, const A extends Acts>(within: Within<D, A>): WithinData<Ix, D, A>
469
+ over<const D extends string, const A extends Acts>(within: Within<D, A>): WithinData<Ix, D, A, Kind>
389
470
  }
390
471
 
391
472
  type SiteData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string, U> =
@@ -393,15 +474,23 @@ type SiteData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string,
393
474
  & { readonly reference: ReferenceTables<Ix> }
394
475
  & UnscopedData<Ix, U, Kind>
395
476
 
477
+ /**
478
+ * `ctx.db.app('<action>')` of a workload site that acts application-wide (a job): the application tables of one of its
479
+ * declared actions, over `ctx.app('<action>')`.
480
+ */
481
+ export interface WorkloadAppData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string> {
482
+ app<const A extends Acts>(action: A): AppData<Ix, A, Kind>
483
+ }
484
+
396
485
  /** The fenced transaction of a job attempt: writes and `tx.sql` commit only while the attempt holds its lease. */
397
486
  export type JobTransaction<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string, JobMailer>> =
398
- & { readonly db: SiteData<K['index'], 'mutation', A, Of<D, 'unscoped'>> }
487
+ & { readonly db: SiteData<K['index'], 'mutation', A, Of<D, 'unscoped'>> & WorkloadAppData<K['index'], 'mutation', A> }
399
488
  & SqlContext<Of<D, 'sql'>, K['index']>
400
489
  & FilesContext<K['index'], 'transaction'>
401
490
  & {
402
491
  /**
403
492
  * `ctx.ref`, located in this transaction: an object it wrote is found before the commit, so the same transaction
404
- * attaches a form's file to the record it just created.
493
+ * reaches the record it just created.
405
494
  */
406
495
  ref<const Act extends A, const Dim extends K['dimension']>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>>
407
496
  }
@@ -420,24 +509,44 @@ type JobMailContext<M extends JobMailer> = [M] extends [never]
420
509
  : { readonly mail: AppMail }
421
510
 
422
511
  /** A send is external I/O that never rolls back, so only a job sends, from the outbox row an endpoint wrote. */
423
- type NoMail = { readonly mail: Unavailable<"an endpoint sends no mail: write an outbox row and kick a job that declares mailer (then: '<job>')"> }
512
+ type NoMail = { readonly mail: Unavailable<"an endpoint sends no mail: write an outbox row and kick a job that declares mailer (kicks: ['<job>'])"> }
424
513
 
425
514
  /** What a cron job's handler receives. Its reads run outside the transaction; every write runs in `ctx.transaction`. */
426
515
  export type JobKitContext<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string, JobMailer>> =
427
516
  & JobContext<A, K['dimension']>
428
- & { readonly db: SiteData<K['index'], 'job', A, Of<D, 'unscoped'>> }
517
+ & { readonly db: SiteData<K['index'], 'job', A, Of<D, 'unscoped'>> & WorkloadAppData<K['index'], 'job', A> }
429
518
  & { readonly sql: Unavailable<'a job runs ctx.sql inside ctx.transaction((tx) => …), as tx.sql'> }
430
519
  & FilesContext<K['index'], 'job'>
431
520
  & JobLinksContext<K['dimension'], A, Of<D, 'issues'>>
432
521
  & JobMailContext<Of<D, 'mailer'>>
433
522
  & { transaction<R>(work: (tx: JobTransaction<K, A, D>) => Promise<R>): Promise<R> }
434
523
 
435
- /** What a webhook delivery's handler receives: one transaction per delivery, idempotent by `ctx.delivery.key`. */
524
+ type BoundToValue = 'a webhook bound to a dimension acts on its bound value: use ctx.refs and ctx.db.on(ref)'
525
+
526
+ /**
527
+ * What a webhook delivery's handler receives: one transaction per delivery, idempotent by `ctx.delivery.key`. A webhook
528
+ * bound to a dimension acts on its bound value through `ctx.refs`; one bound to `'app'` acts application-wide, as its
529
+ * own workload: `ctx.app('<action>')` and `ctx.db.app('<action>')`.
530
+ */
436
531
  export type WebhookKitContext<K extends KitTypes, B extends string, Acts extends string, D extends Declared<SqlMode, unknown, never, never>> =
437
532
  & RequestContext<WebhookPrincipal>
438
- & { readonly refs: { readonly [A in Acts]: Ref<B, A> }; readonly delivery: WebhookDelivery }
533
+ & { readonly delivery: WebhookDelivery }
534
+ & ([B] extends ['app'] ? {
535
+ /** A capability of one of the webhook's declared actions, application-wide, bound to this delivery. */
536
+ app<const A extends Acts>(action: A): AppCapability<A>
537
+ readonly refs: Unavailable<'a webhook bound to the application has no bound value: use ctx.app(<action>) and ctx.db.app(<action>)'>
538
+ }
539
+ : { readonly refs: { readonly [A in Acts]: Ref<B, A> }; readonly app: Unavailable<BoundToValue> })
439
540
  & SqlContext<Of<D, 'sql'>, K['index']>
440
- & { readonly db: SiteData<K['index'], 'mutation', Acts, Of<D, 'unscoped'>> }
541
+ & {
542
+ readonly db:
543
+ & SiteData<K['index'], 'mutation', Acts, Of<D, 'unscoped'>>
544
+ & ([B] extends ['app'] ? WorkloadAppData<K['index'], 'mutation', Acts> : { readonly app: Unavailable<BoundToValue> })
545
+ }
546
+ & {
547
+ /** The stored files: a delivery puts one, and opens one it holds. */
548
+ readonly files: StoredFilesHandle
549
+ }
441
550
  & NoMail
442
551
 
443
552
  type IndexOf<A> = A extends App<infer K> ? K['index'] : never
@@ -449,7 +558,7 @@ type IndexOf<A> = A extends App<infer K> ? K['index'] : never
449
558
  */
450
559
  export type DbHandle<A, F extends TargetForm | 'over', D extends string, Act extends string, K extends DataKind = 'query'> = F extends TargetForm
451
560
  ? Target<IndexOf<A>, D, Act, K, F>
452
- : WithinData<IndexOf<A>, D, Act>
561
+ : WithinData<IndexOf<A>, D, Act, K>
453
562
 
454
563
  /**
455
564
  * The type of `ctx.db.app` of an `app` endpoint, for a function of `src/<slice>/queries.ts` that takes it: