@fabrika/app 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
# references/endpoint.md — endpoints, their handles and raw SQL
|
|
2
|
+
|
|
3
|
+
An endpoint is declared and handled in one place: `r.query({ input, output, access, … }).handle(async ({ input,
|
|
4
|
+
ctx }) => …)` inside `app.group({ callers }, (r) => ({ … }))` of a slice's `endpoints.ts`. The kit types the handler by
|
|
5
|
+
its endpoint: a field the output does not declare, a handle the access form does not give and a write in a query are
|
|
6
|
+
type errors at their line.
|
|
7
|
+
|
|
8
|
+
## Schemas
|
|
9
|
+
|
|
10
|
+
```ts src/schemas.ts
|
|
11
|
+
import { z } from 'zod/v4'
|
|
12
|
+
|
|
13
|
+
export const TaskStatus = z.enum(['open', 'doing', 'done'])
|
|
14
|
+
export const Saved = z.strictObject({ id: z.uuid(), version: z.int() })
|
|
15
|
+
export const ById = z.strictObject({ id: z.uuid() })
|
|
16
|
+
export const Versioned = z.strictObject({
|
|
17
|
+
id: z.uuid(),
|
|
18
|
+
expectVersion: z.int().positive(),
|
|
19
|
+
})
|
|
20
|
+
export const Cursor = z.string().max(500)
|
|
21
|
+
export const OrgInput = z.strictObject({ orgId: z.string().min(1).max(100) })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- `z` comes from `zod/v4`. Objects are `z.strictObject`, never `z.object`. No `transform`, `refine`, `pipe`,
|
|
25
|
+
`preprocess`, `passthrough`, `lazy` or `custom`: the shell is read without running it.
|
|
26
|
+
- `src/schemas.ts` holds what several slices, or a slice and the SPA, share, as literal consts only. A slice declares
|
|
27
|
+
its own schemas in its `endpoints.ts` (`Task`, `NewTask` in `SKILL.md`), and may declare other consts there too.
|
|
28
|
+
- A pure function both the server and the SPA run (a template fill, a label) lives in `src/shared/<name>.ts`, once: a
|
|
29
|
+
handler and a `web/` module import the same function, so the preview and the e-mail cannot drift. A shared module
|
|
30
|
+
imports only `zod/v4` and other modules of `src/shared/` (`shared-import`); no slice, no `src/db/`, no `@fabrika/*`,
|
|
31
|
+
no global object, no environment.
|
|
32
|
+
- `z.iso.datetime()` takes UTC (`…Z`) only; `z.iso.date()` is `YYYY-MM-DD`.
|
|
33
|
+
- Optional or nullable: on a create, a field the caller may leave out is `.optional()`, never a required
|
|
34
|
+
`z.nullable(…)`, and the handler writes `?? null` (`NewTask.dueOn`). On an edit, a field the caller must send is required (`EditTask.title`), one the caller may
|
|
35
|
+
clear is `z.nullable(…)` (`EditTask.dueOn`), and a patch that sends only what changed takes `.optional()` fields: an
|
|
36
|
+
update that sets no column writes nothing and answers the row's current `{ id, version }`. The generated cases send
|
|
37
|
+
no optional field and `null` for a nullable one (`references/tests.md`).
|
|
38
|
+
- A query that needs no input declares none: `r.query({ output, access })`, and the SPA calls it with no argument,
|
|
39
|
+
`api.members.orgs()`.
|
|
40
|
+
|
|
41
|
+
## The access forms and their handles
|
|
42
|
+
|
|
43
|
+
| Access | Use it for | `ctx.db` (query) | adds in a mutation |
|
|
44
|
+
| ---------------------------------------------- | --------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
45
|
+
| `{ action, on: 'task', from: 'id' }` | one object by id; 404 otherwise | `target.get()`; a child table's `list`, `get` | `getForUpdate()`, `update(v, { expectVersion })`, `remove({ expectVersion })`; child writes |
|
|
46
|
+
| `{ action, in: 'project', from: 'projectId' }` | a container; 403 when not held | `target.get()` (the container's row); `target.<table>.list` | `target.<table>.insert(v)` |
|
|
47
|
+
| `{ action, over: 'org' }` | a list across the caller's scopes | `within.<table>.list`, `.count`, `.aggregate` | — (a list write: `sql` or `unscoped`) |
|
|
48
|
+
| `{ action, creates: 'org' }` | a new top-level scope from IAM | — | `created.<table>.insert(v)` |
|
|
49
|
+
| `{ action, app: true }` | data of the whole application | `app.<table>.list`, `.get(id)`; every row of a scoped table | `app.<table>.insert(v)`, `.update(id, v, …)`, `.remove(id, …)` of an application table |
|
|
50
|
+
| `{ member: true }` | any member, no application data | nothing | nothing |
|
|
51
|
+
|
|
52
|
+
- Every endpoint also has `ctx.db.reference.<table>` (reference tables), `ctx.principal`
|
|
53
|
+
(`{ kind: 'user', id, label, email }` for a `human` caller), `ctx.requestId` and `ctx.signal`, which aborts when
|
|
54
|
+
the client leaves or the request runs past its budget (10 s); its transaction then rolls back. Pass it to every
|
|
55
|
+
outbound call. An `on`, `in` or `over` endpoint also reads `ctx.db.app.<table>`: each application table whose read
|
|
56
|
+
action is the endpoint's action, every row, read-only (below).
|
|
57
|
+
- `ctx.principal.email` is the mailbox IAM recorded for the person, lower-cased, from the token: the address to mail,
|
|
58
|
+
not a proof that the person controls it. Mail "send it to me"
|
|
59
|
+
there and take no recipient from the input (`references/public-intake.md`). A `machine` caller
|
|
60
|
+
(`{ kind: 'service', id, label }`) has no address. `label` is for display: the address as the person spelled it.
|
|
61
|
+
- **An endpoint the generated tests cover calls no outside service.** They boot without your variables, so a handler
|
|
62
|
+
that calls one answers 500 in each case, and a request cannot undo the call when its transaction rolls back. Such an
|
|
63
|
+
endpoint records an outbox row in its transaction, and a job sends it with an attempt count and an idempotency key
|
|
64
|
+
(`references/jobs.md`). A call that is the request's whole purpose, such as a test e-mail to the caller, lives in a
|
|
65
|
+
`{ member: true }` endpoint, which they do not cover (`references/public-intake.md`). The framework's own calls (IAM,
|
|
66
|
+
the file store, `ctx.connect`) are not outside.
|
|
67
|
+
- `ctx.principal.label` is a `string`, never `null`: the label IAM holds for the person (the one its directory
|
|
68
|
+
shows), or the id when the token carries none. When the caller owns what it writes (the creator of a deal, the
|
|
69
|
+
assignee of a task nobody was picked for), store `ctx.principal.id` and `ctx.principal.label`, never a
|
|
70
|
+
`ctx.members.get(ctx.principal.id)` lookup: it adds a directory read the approver sees. A picked person is
|
|
71
|
+
`ctx.members.get` (Members below).
|
|
72
|
+
- `ctx.target` is the capability (a `Ref`); its id is `ctx.target.value`. `ctx.within` is the `over` capability.
|
|
73
|
+
- Two writes with the same `expectVersion` (two tabs, a double click) give one 200 and one 409, also when they arrive
|
|
74
|
+
together: the second one finds the version moved.
|
|
75
|
+
- A delete is the exception: of two deletes of one row with the same `expectVersion`, the second finds the row gone and
|
|
76
|
+
answers 404, not 409. So does any write that loses to a delete. A test of the race expects `[200, 404]`.
|
|
77
|
+
- A capability reads its own target, whatever its action: `tasks.assign` reads the task it assigns. A column with
|
|
78
|
+
its own read action is left out for every other action.
|
|
79
|
+
- Never use `{ member: true }` for application data. `callers` is `['human']`, `['machine']` or both; every group of
|
|
80
|
+
one mount names the same callers.
|
|
81
|
+
|
|
82
|
+
### Data of the whole application: `app: true`
|
|
83
|
+
|
|
84
|
+
A code list an administrator edits, operational counts, the log of an export of every record: data that belongs to no
|
|
85
|
+
dimension value takes `access: { action, app: true }`.
|
|
86
|
+
|
|
87
|
+
- The proxy admits it only for a caller who holds the action as a global grant (a role granted without a scope); a
|
|
88
|
+
grant in any one scope is 403. So declare it only where every caller is application-wide; a list a scoped member
|
|
89
|
+
also uses stays `over`.
|
|
90
|
+
- `ctx.db.app` holds every application table (`scope: { app: '<why>' }`, `references/table-and-migration.md`), with
|
|
91
|
+
each operation's own action, and every scoped table, read-only and every row: `list`, `count` and `aggregate` where
|
|
92
|
+
the endpoint's action is its read action or one its policy names in `actions.appRead`, with the columns of that
|
|
93
|
+
action (a column whose own read action is another is left out, as for a `Ref`), and only `count({ where })` where it
|
|
94
|
+
is one of `actions.appCount`. `ctx.app` is the capability.
|
|
95
|
+
- So an export, a bulk send or an operations counter runs under an action of its own, not the read action: the table
|
|
96
|
+
names it (`appRead: ['candidates.export']`, `appCount: ['candidates.count']`), and a global holder of only the read
|
|
97
|
+
action gets 403 from that endpoint. An `appCount` action has no `aggregate`: a group, a minimum or a sum is a row's
|
|
98
|
+
value. Count each value of a known set with its own `where` (`count({ where: [['status', '=', status]] })`).
|
|
99
|
+
- An endpoint `on`, `in` or `over` a dimension reads an application table too, read-only and every row, where its
|
|
100
|
+
action is the table's read action: `list`, `count`, `aggregate` and `get(id)` of `ctx.db.app.<table>`, and no write.
|
|
101
|
+
With another action the table is a type error that names its read action, and a refusal at run time. So a code list
|
|
102
|
+
an administrator edits is validated by reading it: an unknown or retired value answers `BadRequestError` (400) that
|
|
103
|
+
names it (`decide` in `references/public-intake.md`), not a foreign key's 500.
|
|
104
|
+
- An audited export of every record is one `app` mutation of its own action, which the records' policy names in
|
|
105
|
+
`appRead`: it lists the records through `ctx.db.app.<table>` and inserts its log row into an application table whose
|
|
106
|
+
insert action is the export's action (`references/public-intake.md`). It needs no `sql` and no `unscoped`.
|
|
107
|
+
- An `app` endpoint has no `ctx.target`, `ctx.within`, `ctx.members` or `ctx.links`. A link procedure and an export
|
|
108
|
+
keep their own forms.
|
|
109
|
+
- The approver sees it as "app-wide: a global grant", and a change of an endpoint to or from `app: true` widens.
|
|
110
|
+
|
|
111
|
+
### The methods of each handle
|
|
112
|
+
|
|
113
|
+
| Handle | Methods |
|
|
114
|
+
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
115
|
+
| `target` (the object of `on`, the container of `in`) | `get()`; under `on`, in a mutation, `getForUpdate()`, `update(values, { expectVersion })`, `remove({ expectVersion })` |
|
|
116
|
+
| `target.<child table>` (`on`) | `list(options)`, `get(id)`; in a mutation `insert(values)`, `update(id, values, { expectVersion })`, `remove(id, { expectVersion })`; no `getForUpdate` |
|
|
117
|
+
| `target.<table>` (`in`), `created.<table>` (`creates`) | `list(options)` of a contained table; in a mutation `insert(values)` |
|
|
118
|
+
| `within.<table>` (`over`) | `list(options)`, `count({ where })`, `aggregate(spec)` |
|
|
119
|
+
| `reference.<table>` | `list(options)`, `count({ where })`: no `get`; look a row up by its key with `where` (`priorities.get` below) |
|
|
120
|
+
| `app.<table>` (`app: true`), an application table | `list(options)`, `count({ where })`, `aggregate(spec)`, `get(id)`; in a mutation `getForUpdate(id)`, `insert(values)`, `update(id, values, { expectVersion })`, `remove(id, { expectVersion })` |
|
|
121
|
+
| `app.<table>` (`app: true`), a scoped table | `list(options)` (read action or `appRead`), `count({ where })` (also `appCount`), `aggregate(spec)` |
|
|
122
|
+
| `app.<table>` (`on`, `in`, `over`) | an application table whose read action is the endpoint's: `list(options)`, `count({ where })`, `aggregate(spec)`, `get(id)`; no write |
|
|
123
|
+
| `unscoped.<table>` (declared) | `list`, `count`, `aggregate`, `get(id)` (not of a reference table or an inbox, which have no key); with `'write'` also `insert`, `update`, `remove` |
|
|
124
|
+
|
|
125
|
+
`{ expectVersion }` is taken only on a table with `version`.
|
|
126
|
+
|
|
127
|
+
### Which dimension `over` names
|
|
128
|
+
|
|
129
|
+
`over` names the nearest dimension of the listed rows' chain: the dimension the table's rows lie in (tasks lie in a
|
|
130
|
+
project: `over: 'project'`; projects lie in an org: `over: 'org'`), a child table's parent (`task_comments`:
|
|
131
|
+
`over: 'task'`), or a top-level table's own dimension. The caller's grants on that dimension and on every dimension
|
|
132
|
+
containing it count; a grant held only below it does not.
|
|
133
|
+
|
|
134
|
+
- A farther dimension reaches fewer rows: `over: 'org'` on tasks ignores a grant held on one project, which answers
|
|
135
|
+
403. With roles granted only in the org, both list the same rows today, so the difference shows only later.
|
|
136
|
+
- The dimension is part of the endpoint's requirement, and the approver's diff shows any change of a requirement as a
|
|
137
|
+
widening. Choose it by this rule when you write the endpoint; do not change it to pass a test.
|
|
138
|
+
- `ctx.db.within` reaches every table whose chain holds the dimension, so `over: 'org'` also types tasks two levels
|
|
139
|
+
down. The rule still names `project` for a list of tasks.
|
|
140
|
+
|
|
141
|
+
### The caller's scopes: `ctx.within.values`
|
|
142
|
+
|
|
143
|
+
`ctx.within.values` is the list of values of the `over` dimension in which the caller holds the action, or `'all'`
|
|
144
|
+
for a global grant (an operator's or an auditor's role granted without a scope), which cannot be listed.
|
|
145
|
+
`ctx.within.containers` holds the grants on dimensions above it. `orgs` below answers "the orgs I work in", for a
|
|
146
|
+
picker of the org a new project goes into; an `'all'` caller types the org instead.
|
|
147
|
+
|
|
148
|
+
## Lists
|
|
149
|
+
|
|
150
|
+
- `list({ where, orderBy, limit, after })` answers `{ items, next }`: a keyset page of at most `limit` rows (50 by
|
|
151
|
+
default, 500 at most). Pass `next` back as `after`; it is `null` on the last page.
|
|
152
|
+
- `where` is AND of `[column, operator, value]`: `=`, `<>`, `<`, `<=`, `>`, `>=`, `in` and `not in` (a list),
|
|
153
|
+
`like`, `ilike`, `not like`, `not ilike`, `contains`. Optional filters are conditional spreads, each tuple
|
|
154
|
+
`as const` (`list` in `SKILL.md`): `...(input.status === undefined ? [] : [['status', '=', input.status] as const])`.
|
|
155
|
+
There is no `is null` and no OR: write that in `ctx.sql`.
|
|
156
|
+
- `orderBy` is `[[column, 'asc' | 'desc']]`; the key is added last as the tiebreak.
|
|
157
|
+
- `count({ where })` answers a number. `aggregate({ groupBy, count, sum, min, max })` takes lists of column names
|
|
158
|
+
and answers one row per group: the `groupBy` columns, `count` as a string (Postgres `bigint`), and `sum_<column>`,
|
|
159
|
+
`min_<column>`, `max_<column>` per listed column. Over a `deals` table, `{ groupBy: ['stage'], count: true, sum:
|
|
160
|
+
['value'] }` answers `{ stage, count, sum_value }` per stage.
|
|
161
|
+
`sum_<column>` is typed `string | number | null` for every column: at runtime text for an integer or `numeric`
|
|
162
|
+
column, a number for a floating-point one. It is `null` when no row of the group has a value: over no rows (no
|
|
163
|
+
`groupBy`), or a nullable column that is null in every row of the group. `min_`/`max_` are `null` the same way. A
|
|
164
|
+
caller whose capability reaches no value gets no row at all (`[]`).
|
|
165
|
+
- Convert a sum with a checked conversion, never a bare `Number(…)`: past 2^53 − 1 a number rounds silently.
|
|
166
|
+
`exactInteger` in `logic.ts` below takes what the type gives without `null` and refuses instead; a missing sum is
|
|
167
|
+
a total of zero (`exactInteger(row.sum_value ?? 0)`). A count is far from that limit, so `Number(…)` is fine
|
|
168
|
+
there. Or keep the sum a string in the output (`z.string().regex(/^-?[0-9]+$/)`).
|
|
169
|
+
|
|
170
|
+
## Business rules: `logic.ts`
|
|
171
|
+
|
|
172
|
+
```ts src/tasks/logic.ts
|
|
173
|
+
export const canClose = (status: 'open' | 'doing' | 'done'): boolean =>
|
|
174
|
+
status === 'doing'
|
|
175
|
+
|
|
176
|
+
/** An aggregate's sum, text or a number; one past 2^53 - 1 is refused, never rounded. */
|
|
177
|
+
export const exactInteger = (sum: string | number): number => {
|
|
178
|
+
const value = Number(sum)
|
|
179
|
+
if (!Number.isSafeInteger(value)) {
|
|
180
|
+
throw new RangeError('the total is beyond the safe integer range')
|
|
181
|
+
}
|
|
182
|
+
return value
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`logic.ts` takes and answers plain values: no `ctx`, no handle, no row it was not given. Test it without a database.
|
|
187
|
+
|
|
188
|
+
## A slice of its own
|
|
189
|
+
|
|
190
|
+
```ts src/projects/endpoints.ts
|
|
191
|
+
import { fromPage } from '@fabrika/app'
|
|
192
|
+
import { z } from 'zod/v4'
|
|
193
|
+
import { app } from '../app-kit'
|
|
194
|
+
import { Cursor, Saved } from '../schemas'
|
|
195
|
+
|
|
196
|
+
const Project = z.strictObject({
|
|
197
|
+
id: z.uuid(),
|
|
198
|
+
name: z.string(),
|
|
199
|
+
version: z.int(),
|
|
200
|
+
})
|
|
201
|
+
const ProjectPage = z.strictObject({
|
|
202
|
+
items: z.array(Project),
|
|
203
|
+
next: z.nullable(z.string()),
|
|
204
|
+
})
|
|
205
|
+
const ByOrg = z.strictObject({
|
|
206
|
+
orgId: z.string().min(1).max(100),
|
|
207
|
+
after: Cursor.optional(),
|
|
208
|
+
})
|
|
209
|
+
const NewProject = z.strictObject({
|
|
210
|
+
orgId: z.string().min(1).max(100),
|
|
211
|
+
name: z.string().min(1).max(200),
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
export const projects = app.group({ callers: ['human'] }, (r) => ({
|
|
215
|
+
list: r.query({
|
|
216
|
+
input: ByOrg,
|
|
217
|
+
output: ProjectPage,
|
|
218
|
+
access: { action: 'tasks.read', in: 'org', from: 'orgId' },
|
|
219
|
+
})
|
|
220
|
+
.handle(async ({ input, ctx }) =>
|
|
221
|
+
fromPage(
|
|
222
|
+
Project,
|
|
223
|
+
await ctx.db.target.projects.list({
|
|
224
|
+
orderBy: [['name', 'asc']],
|
|
225
|
+
after: input.after,
|
|
226
|
+
}),
|
|
227
|
+
)
|
|
228
|
+
),
|
|
229
|
+
create: r.mutation({
|
|
230
|
+
input: NewProject,
|
|
231
|
+
output: Saved,
|
|
232
|
+
access: { action: 'tasks.write', in: 'org', from: 'orgId' },
|
|
233
|
+
})
|
|
234
|
+
.handle(({ input, ctx }) =>
|
|
235
|
+
ctx.db.target.projects.insert({ name: input.name })
|
|
236
|
+
),
|
|
237
|
+
}))
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
A child table is reached through its parent's `on` endpoint. A table without `version` answers `{ id }`:
|
|
241
|
+
|
|
242
|
+
```ts src/comments/endpoints.ts
|
|
243
|
+
import { fromPage } from '@fabrika/app'
|
|
244
|
+
import { z } from 'zod/v4'
|
|
245
|
+
import { app } from '../app-kit'
|
|
246
|
+
import { Cursor } from '../schemas'
|
|
247
|
+
|
|
248
|
+
const Comment = z.strictObject({
|
|
249
|
+
id: z.uuid(),
|
|
250
|
+
body: z.string(),
|
|
251
|
+
createdBy: z.string(),
|
|
252
|
+
createdAt: z.iso.datetime(),
|
|
253
|
+
})
|
|
254
|
+
const CommentPage = z.strictObject({
|
|
255
|
+
items: z.array(Comment),
|
|
256
|
+
next: z.nullable(z.string()),
|
|
257
|
+
})
|
|
258
|
+
const ByTask = z.strictObject({ taskId: z.uuid(), after: Cursor.optional() })
|
|
259
|
+
const NewComment = z.strictObject({
|
|
260
|
+
taskId: z.uuid(),
|
|
261
|
+
body: z.string().min(1).max(2000),
|
|
262
|
+
})
|
|
263
|
+
const Added = z.strictObject({ id: z.uuid() })
|
|
264
|
+
|
|
265
|
+
export const comments = app.group({ callers: ['human'] }, (r) => ({
|
|
266
|
+
list: r.query({
|
|
267
|
+
input: ByTask,
|
|
268
|
+
output: CommentPage,
|
|
269
|
+
access: { action: 'tasks.read', on: 'task', from: 'taskId' },
|
|
270
|
+
})
|
|
271
|
+
.handle(async ({ input, ctx }) =>
|
|
272
|
+
fromPage(
|
|
273
|
+
Comment,
|
|
274
|
+
await ctx.db.target.task_comments.list({
|
|
275
|
+
orderBy: [['created_at', 'asc']],
|
|
276
|
+
after: input.after,
|
|
277
|
+
}),
|
|
278
|
+
)
|
|
279
|
+
),
|
|
280
|
+
add: r.mutation({
|
|
281
|
+
input: NewComment,
|
|
282
|
+
output: Added,
|
|
283
|
+
access: { action: 'tasks.write', on: 'task', from: 'taskId' },
|
|
284
|
+
})
|
|
285
|
+
.handle(({ input, ctx }) =>
|
|
286
|
+
ctx.db.target.task_comments.insert({ body: input.body })
|
|
287
|
+
),
|
|
288
|
+
}))
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
A reference table belongs to no scope, so a `{ member: true }` endpoint reads it. It has no `get`: look a row up by its
|
|
292
|
+
key with a `where`:
|
|
293
|
+
|
|
294
|
+
```ts src/priorities/endpoints.ts
|
|
295
|
+
import { fromPage, fromRow, NotFoundError } from '@fabrika/app'
|
|
296
|
+
import { z } from 'zod/v4'
|
|
297
|
+
import { app } from '../app-kit'
|
|
298
|
+
|
|
299
|
+
const Priority = z.strictObject({ code: z.string(), label: z.string() })
|
|
300
|
+
const PriorityPage = z.strictObject({
|
|
301
|
+
items: z.array(Priority),
|
|
302
|
+
next: z.nullable(z.string()),
|
|
303
|
+
})
|
|
304
|
+
const ByCode = z.strictObject({ code: z.string().min(1).max(20) })
|
|
305
|
+
|
|
306
|
+
export const priorities = app.group({ callers: ['human'] }, (r) => ({
|
|
307
|
+
list: r.query({ output: PriorityPage, access: { member: true } })
|
|
308
|
+
.handle(async ({ ctx }) =>
|
|
309
|
+
fromPage(
|
|
310
|
+
Priority,
|
|
311
|
+
await ctx.db.reference.priorities.list({ orderBy: [['rank', 'asc']] }),
|
|
312
|
+
)
|
|
313
|
+
),
|
|
314
|
+
get: r.query({ input: ByCode, output: Priority, access: { member: true } })
|
|
315
|
+
.handle(async ({ input, ctx }) => {
|
|
316
|
+
const page = await ctx.db.reference.priorities.list({
|
|
317
|
+
where: [['code', '=', input.code]],
|
|
318
|
+
limit: 1,
|
|
319
|
+
})
|
|
320
|
+
const row = page.items[0]
|
|
321
|
+
if (row === undefined) throw new NotFoundError('no such priority')
|
|
322
|
+
return fromRow(Priority, row)
|
|
323
|
+
}),
|
|
324
|
+
}))
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
Mount every group in `fabrika.contract.ts`:
|
|
328
|
+
`api: c.rpc('/api', { projects, tasks, comments, reports, members, priorities })`. A procedure is served at
|
|
329
|
+
`POST /api/<group>.<endpoint>`, and answers `{ result: <output> }` (`references/errors.md`).
|
|
330
|
+
|
|
331
|
+
## Rows and outputs
|
|
332
|
+
|
|
333
|
+
- `fromRow(Schema, row)` turns snake_case columns into the schema's camelCase fields, a `Date` into ISO text, drops a
|
|
334
|
+
column the schema does not declare, and parses the rest. `fromPage(Schema, page)` does it for every row of a
|
|
335
|
+
`list` answer and keeps its `next`: the whole handler of a list is `fromPage(Item, await ….list({ … }))`.
|
|
336
|
+
- `RowOf<typeof tables, '<table>'>` (`@fabrika/db/tables`) is the row a table's read action reads, for a function that
|
|
337
|
+
takes one: `const toTask = (row: RowOf<typeof tables, 'tasks'>) => fromRow(Task, row)`.
|
|
338
|
+
|
|
339
|
+
## Shared data access: `queries.ts`
|
|
340
|
+
|
|
341
|
+
A function of the slice's `queries.ts` takes the handle as a parameter typed with `DbHandle` and returns plain values.
|
|
342
|
+
It decides no authorization: the capability already bounds the handle.
|
|
343
|
+
|
|
344
|
+
```ts src/reports/queries.ts
|
|
345
|
+
import type { DbHandle } from '@fabrika/app/contract'
|
|
346
|
+
import type { app } from '../app-kit'
|
|
347
|
+
|
|
348
|
+
export const statusCounts = async (
|
|
349
|
+
within: DbHandle<typeof app, 'over', 'project', 'tasks.read'>,
|
|
350
|
+
) => {
|
|
351
|
+
const rows = await within.tasks.aggregate({ groupBy: ['status'], count: true })
|
|
352
|
+
const count = (status: 'open' | 'doing' | 'done') =>
|
|
353
|
+
Number(rows.find((row) => row.status === status)?.count ?? 0)
|
|
354
|
+
return { open: count('open'), doing: count('doing'), done: count('done') }
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
`DbHandle<typeof app, 'on' | 'in', '<dimension>', '<action>', 'mutation'>` is `ctx.db.target` of that form;
|
|
359
|
+
`DbHandle<typeof app, 'over', …>` is `ctx.db.within`.
|
|
360
|
+
|
|
361
|
+
## Raw SQL: `sql: 'read'`
|
|
362
|
+
|
|
363
|
+
Raw SQL is legitimate for a join, a report, an `OR` or `IS NULL` filter, or a bulk write. Declare it on the endpoint;
|
|
364
|
+
`ctx.sql` exists only there.
|
|
365
|
+
|
|
366
|
+
```ts src/reports/endpoints.ts
|
|
367
|
+
import { attachment } from '@fabrika/app/contract'
|
|
368
|
+
import { z } from 'zod/v4'
|
|
369
|
+
import { app } from '../app-kit'
|
|
370
|
+
import { TaskStatus } from '../schemas'
|
|
371
|
+
import { statusCounts } from './queries'
|
|
372
|
+
|
|
373
|
+
const Workload = z.strictObject({
|
|
374
|
+
orgId: z.string().min(1).max(100),
|
|
375
|
+
status: TaskStatus.optional(),
|
|
376
|
+
})
|
|
377
|
+
const ByProject = z.strictObject({ projectId: z.uuid() })
|
|
378
|
+
const WorkloadRow = z.strictObject({
|
|
379
|
+
assignee: z.nullable(z.string()),
|
|
380
|
+
open: z.int(),
|
|
381
|
+
overdue: z.int(),
|
|
382
|
+
})
|
|
383
|
+
const WorkloadReport = z.strictObject({ rows: z.array(WorkloadRow) })
|
|
384
|
+
const Counts = z.strictObject({ open: z.int(), doing: z.int(), done: z.int() })
|
|
385
|
+
const TaskExportRow = z.strictObject({
|
|
386
|
+
title: z.string(),
|
|
387
|
+
status: TaskStatus,
|
|
388
|
+
assignee: z.nullable(z.string()),
|
|
389
|
+
dueOn: z.nullable(z.string()),
|
|
390
|
+
})
|
|
391
|
+
const TaskExport = attachment({
|
|
392
|
+
format: 'csv',
|
|
393
|
+
filename: 'tasks.csv',
|
|
394
|
+
row: TaskExportRow,
|
|
395
|
+
maxBytes: 5_000_000,
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
export const reports = app.group({ callers: ['human'] }, (r) => ({
|
|
399
|
+
workload: r.query({
|
|
400
|
+
input: Workload,
|
|
401
|
+
output: WorkloadReport,
|
|
402
|
+
access: { action: 'tasks.read', in: 'org', from: 'orgId' },
|
|
403
|
+
sql: 'read',
|
|
404
|
+
})
|
|
405
|
+
.handle(async ({ input, ctx }) => {
|
|
406
|
+
const status = input.status ?? null
|
|
407
|
+
const rows = await ctx.sql(WorkloadRow)`
|
|
408
|
+
select t.assignee,
|
|
409
|
+
count(*) filter (where t.status <> 'done')::int as open,
|
|
410
|
+
count(*) filter (where t.status <> 'done' and t.due_on < current_date)::int as overdue
|
|
411
|
+
from tasks t
|
|
412
|
+
where ${ctx.sql.scope(ctx.target, 'tasks', 't')}
|
|
413
|
+
and (${status}::text is null or t.status::text = ${status})
|
|
414
|
+
group by t.assignee
|
|
415
|
+
order by t.assignee`
|
|
416
|
+
return { rows }
|
|
417
|
+
}),
|
|
418
|
+
counts: r.query({
|
|
419
|
+
output: Counts,
|
|
420
|
+
access: { action: 'tasks.read', over: 'project' },
|
|
421
|
+
})
|
|
422
|
+
.handle(({ ctx }) => statusCounts(ctx.db.within)),
|
|
423
|
+
exportProject: r.mutation({
|
|
424
|
+
input: ByProject,
|
|
425
|
+
output: TaskExport,
|
|
426
|
+
access: { action: 'tasks.read', in: 'project', from: 'projectId' },
|
|
427
|
+
sql: 'read',
|
|
428
|
+
})
|
|
429
|
+
.handle(async ({ ctx }) => {
|
|
430
|
+
const rows = await ctx.sql(TaskExportRow)`
|
|
431
|
+
select t.title, t.status, t.assignee, t.due_on::text as due_on
|
|
432
|
+
from tasks t
|
|
433
|
+
where ${ctx.sql.scope(ctx.target, 'tasks', 't')}
|
|
434
|
+
order by t.title
|
|
435
|
+
limit 10000`
|
|
436
|
+
await ctx.db.target.project_exports.insert({ row_count: rows.length })
|
|
437
|
+
return rows
|
|
438
|
+
}),
|
|
439
|
+
}))
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
- Every `${…}` of a value is a bound parameter, never SQL text. The one exception is `${ctx.sql.scope(…)}`: it is SQL
|
|
443
|
+
(the predicate) around its own bound values, and it may have no value at all (`(true)`, `(false)`). Put no
|
|
444
|
+
interpolation inside quotes, a quoted name, a dollar body or a comment: the tag refuses it.
|
|
445
|
+
- **Scope every scoped table the statement reads with `${ctx.sql.scope(capability, '<table>', '<alias>')}`.**
|
|
446
|
+
`ctx.sql` has no scope predicate of its own. The fragment is the capability's whole predicate on that table: for a
|
|
447
|
+
`Ref` the object's column and every link of its chain, so a project moved to another org is out; for a `Within` its
|
|
448
|
+
values, each container grant on its own column, every row for a global grant and none for an empty one. The
|
|
449
|
+
capability is the access form's: `ctx.target` for `on` and `in`, `ctx.within` for `over`; a job passes the `Ref` or
|
|
450
|
+
`Within` it minted to `tx.sql.scope`. A table the capability does not reach is a type error at the table name. A
|
|
451
|
+
reference table is shared by every scope and needs no fragment, and no statement writes it. A form's inbox needs none either; read it from a site that holds the inbox's
|
|
452
|
+
`read` action (the intake job of `references/public-intake.md`).
|
|
453
|
+
- **A fragment carries its action into the statement.** On the table an `update` or `delete` writes it needs the
|
|
454
|
+
table's own update or delete action (`ctx.within('tasks.write', 'project')` for `update tasks`), so a read action
|
|
455
|
+
never scopes a write; anywhere else it needs an action the handles read the table with. `insert … on conflict do
|
|
456
|
+
update … where ${…}` is an update of the inserted table. The fragment answers for its own query block: a CTE that
|
|
457
|
+
updates and a statement that deletes take one capability each, and another occurrence of the written table (a
|
|
458
|
+
subquery, `from tasks other`) reads with a read capability under its own alias. A refused fragment fails the call
|
|
459
|
+
before anything is sent.
|
|
460
|
+
- **A fragment is a conjunct.** It stands alone, joined by AND, at the top level of a WHERE or JOIN … ON clause whose
|
|
461
|
+
top level holds no OR: `where (<the whole filter>) and ${…}`, `join projects p on p.id = t.project_id and ${…}`.
|
|
462
|
+
The tag refuses `where a or ${…}`, `where a or b and ${…}`, `not ${…}`, a fragment in parentheses or outside such a
|
|
463
|
+
clause. Never scope by hand with `ctx.target.value` alone: it drops the chain. The generated tests check that an `in`
|
|
464
|
+
endpoint with `sql` leaks nothing of another scope.
|
|
465
|
+
- Rows are parsed like `fromRow` (snake_case to camelCase), then by the schema. Postgres sends `bigint` and `numeric`
|
|
466
|
+
as text: cast a count (`count(*)::int`), and a sum to the type its output declares (`::int` for an integer sum that
|
|
467
|
+
fits, `::float8` for a fractional one), or keep it a string. A sum over no rows is `null`: `coalesce(sum(…), 0)`.
|
|
468
|
+
- An optional filter is `(${value}::text is null or <column> = ${value})`.
|
|
469
|
+
- One statement: `select`, `with`, `values`, plus `insert`, `update` and `delete` on a `'write'` site (a mutation, a
|
|
470
|
+
job or a webhook). Refused before anything is sent: DDL, `SET`, `COPY`, transaction control, another schema, any
|
|
471
|
+
`fabrika_*` or `pg_*` name, and calls outside the reviewed built-ins. A function your migrations declare is refused
|
|
472
|
+
too: call it from a view. These name rules are defense in depth; the runtime role (DML only) is the boundary.
|
|
473
|
+
- A query runs in a read-only transaction; a mutation's `ctx.sql` runs in the same transaction as its handles.
|
|
474
|
+
|
|
475
|
+
## A file download: `attachment`
|
|
476
|
+
|
|
477
|
+
`output: attachment({ format: 'csv', filename, row, maxBytes, maxRows? })` (`@fabrika/app/contract`) makes the endpoint
|
|
478
|
+
answer a file: the handler returns the rows (an array of `row`), and the framework writes the CSV. `exportProject`
|
|
479
|
+
above is one. `format: 'xlsx'` writes a spreadsheet instead, from the same rows.
|
|
480
|
+
|
|
481
|
+
- `row` is a `z.strictObject` of scalar fields (string, number, boolean, enum, literal; nullable or optional); its
|
|
482
|
+
field names are the header line, in their order. `filename` is fixed (`[A-Za-z0-9._-]`, ending in `.csv`, or
|
|
483
|
+
`.xlsx` for an XLSX).
|
|
484
|
+
- The framework writes RFC 4180 CSV in UTF-8 with a byte order mark, and puts `'` before every text cell a spreadsheet
|
|
485
|
+
would run as a formula (one that starts with `=`, `+`, `-`, `@`, a tab or a line break, also after spaces). Never
|
|
486
|
+
write CSV by hand.
|
|
487
|
+
- An XLSX has one sheet with typed cells: a number cell, a boolean cell, and a date cell for a string column whose
|
|
488
|
+
schema is `z.iso.date()` or `z.iso.datetime()` (shown in UTC); every other value is text, which no spreadsheet runs
|
|
489
|
+
as a formula, so it keeps its `=`. Its parts take about 3.5 KB beyond the cells: declare `maxBytes` above that.
|
|
490
|
+
Never write an XLSX by hand or add a library for it.
|
|
491
|
+
- `maxBytes` (at most 16 MiB) and `maxRows` (10 000 unless declared, at most 100 000) bound the file; a cell holds at
|
|
492
|
+
most 32 767 characters. Past a limit the call fails with a 500 and sends no file. Bound the rows in the query too.
|
|
493
|
+
- **An audited export is a mutation** that writes its log row through `ctx.db` and returns the rows: `exportProject`
|
|
494
|
+
inserts a `project_exports` row (who, when, how many rows) in the invocation's one transaction. The file is encoded
|
|
495
|
+
before the transaction commits, so a file over its limit keeps no log row, and no file leaves without its row. The
|
|
496
|
+
log table lies in the scope the export reads (a child of `projects`), so the export's own capability writes it. An
|
|
497
|
+
export of every record is an `app` mutation that writes an application table instead (`app: true` above).
|
|
498
|
+
- A mutation may declare `sql: 'read'`: its statements only read, and its handles still write.
|
|
499
|
+
- The SPA calls `download(api.reports.exportProject, { projectId })` from `@fabrika/app/client`: it saves the file and
|
|
500
|
+
answers it as a `File`. `api.reports.exportProject` is not a function. A refusal is an `RpcError`.
|
|
501
|
+
- An export (`app.export`) answers JSON only; an `attachment()` is for an RPC or a link procedure.
|
|
502
|
+
|
|
503
|
+
## Rows outside the capability: `unscoped`
|
|
504
|
+
|
|
505
|
+
`unscoped: { tasks: 'read' }` gives `ctx.db.unscoped.tasks` with no scope predicate (every org), still guarded and in
|
|
506
|
+
the invocation's transaction. `'write'` (a mutation, a job, a webhook) also inserts, updates and removes; an insert
|
|
507
|
+
then names the chain columns itself. Use it for an operator's cross-scope view, never to dodge a capability the caller
|
|
508
|
+
should hold. The generated tests skip the other-scope leak case for such an endpoint.
|
|
509
|
+
|
|
510
|
+
## Members
|
|
511
|
+
|
|
512
|
+
`ctx.members` of an endpoint `on` or `in` a dimension reads the members of the nearest dimension, its own or one
|
|
513
|
+
containing it, that declares `members`: a task endpoint reads its org's. A dimension with no such container has none.
|
|
514
|
+
|
|
515
|
+
- Every such endpoint: `ctx.members.get(principalId)` answers `{ principalId, label, disabled }` when that principal
|
|
516
|
+
is a member of the value the object lies in, and `null` otherwise (another org's member, an unknown id). IAM checks
|
|
517
|
+
the endpoint's own action on the org itself, so `tasks.assign` validates the person it assigns without a second
|
|
518
|
+
action; a caller who holds the action only on a project is refused (403). Use it for every picked person: store
|
|
519
|
+
`member.principalId` and `member.label`, never a label from the input.
|
|
520
|
+
- `disabled` is a person an operator disabled in IAM: still a member, but unable to sign in. Refuse to assign one
|
|
521
|
+
(`assign` in `SKILL.md`), and leave them out of a picker.
|
|
522
|
+
- The approver sees each endpoint that reads a directory ("looks up one member of `org`"): a new one widens.
|
|
523
|
+
- With the directory's `members.list` action (`tasks.read` here) also `ctx.members.list()`, answering
|
|
524
|
+
`{ principalId, label, roles, disabled }` per member, for an assignee picker. IAM checks that action on the org
|
|
525
|
+
itself too.
|
|
526
|
+
- `label` is `null` unless the caller also holds `members.manage` there, or the dimension opts in with
|
|
527
|
+
`directory: 'labels'`, which widens like any other access change: then every endpoint below it reads labels too.
|
|
528
|
+
- Under `directory: 'labels'` IAM answers every member's label, so `label` is a `string` in `get` and `list()` of every
|
|
529
|
+
endpoint below it (an IAM answer without one is a 500). Elsewhere it is `string | null`: store it in a nullable column
|
|
530
|
+
or output, never with `!` or a cast.
|
|
531
|
+
- With the dimension's own `members.manage` action: `list(ref)`, `assign(ref, { email } | { principalId }, role)` and
|
|
532
|
+
`revoke(ref, …)`, one change per request. `list(ref)` adds each member's `email` and `lastSignInAt` (unix seconds of
|
|
533
|
+
its last sign-in to this application, `null` for one that never signed in) and the open `invitations`: show a person
|
|
534
|
+
as invited, active or disabled from these, never from a column of your own.
|
|
535
|
+
- An application whose roles are global grants (staff, no dimension with members) declares its own people in the
|
|
536
|
+
vocabulary: `members: { manage: 'people.manage', list: 'people.list' }`, plus `directory: 'labels'` to name them.
|
|
537
|
+
An `app: true` endpoint of `people.list` reads `ctx.members.list({ after })`: everyone holding a role of the
|
|
538
|
+
application, `{ principalId, label, email, lastSignInAt, roles: [{ role, scope }], disabled, affiliation }`, 100 a page
|
|
539
|
+
with `next`. Name the author of a stored change from it, never from a copied label. A holder of `people.manage`
|
|
540
|
+
grants the roles in IAM's console; build no role management of your own (`references/public-intake.md`).
|
|
541
|
+
- A partner company's people: a role with `external: true` whose `assignableBy` names an app-wide staff role.
|
|
542
|
+
`assign(ref, { email }, 'partner', { label: company.name, message })` mails the invitation with the company's name
|
|
543
|
+
(≤ 200 characters, one line) and a note (≤ 1000 characters); a new address chooses its password from the mail and
|
|
544
|
+
lands in the application. `{ resend: true }` sends an open invitation again with a new link and 7 days;
|
|
545
|
+
`revoke(ref, { invitationId })` cancels one; `revoke(ref, { principalId })` removes a person, whose access ends
|
|
546
|
+
within the 300 s token lifetime. An address the organization names is refused (409 `internal_mailbox`). A company's
|
|
547
|
+
deactivation is the application's own flag: check it where the endpoint loads the company row.
|
|
548
|
+
- Name a picked person's field by its role (`assignee`, `newOwner`), not `ownerId` or `userId`: the checker reads
|
|
549
|
+
those as the caller's own identity (`client-identity`).
|
|
550
|
+
|
|
551
|
+
```ts src/members/endpoints.ts
|
|
552
|
+
import { z } from 'zod/v4'
|
|
553
|
+
import { app } from '../app-kit'
|
|
554
|
+
import { OrgInput } from '../schemas'
|
|
555
|
+
|
|
556
|
+
const Member = z.strictObject({
|
|
557
|
+
principalId: z.string(),
|
|
558
|
+
label: z.nullable(z.string()),
|
|
559
|
+
})
|
|
560
|
+
const Members = z.strictObject({ items: z.array(Member) })
|
|
561
|
+
const MyOrgs = z.strictObject({ all: z.boolean(), orgs: z.array(z.string()) })
|
|
562
|
+
const Invite = z.strictObject({
|
|
563
|
+
orgId: z.string().min(1).max(100),
|
|
564
|
+
email: z.email(),
|
|
565
|
+
role: z.enum(['owner', 'member']),
|
|
566
|
+
})
|
|
567
|
+
const Invited = z.strictObject({ invitationId: z.string() })
|
|
568
|
+
const Remove = z.strictObject({
|
|
569
|
+
orgId: z.string().min(1).max(100),
|
|
570
|
+
principalId: z.string(),
|
|
571
|
+
})
|
|
572
|
+
const Removed = z.strictObject({ ok: z.boolean() })
|
|
573
|
+
|
|
574
|
+
export const members = app.group({ callers: ['human'] }, (r) => ({
|
|
575
|
+
list: r.query({
|
|
576
|
+
input: OrgInput,
|
|
577
|
+
output: Members,
|
|
578
|
+
access: { action: 'tasks.read', in: 'org', from: 'orgId' },
|
|
579
|
+
})
|
|
580
|
+
.handle(async ({ ctx }) => ({
|
|
581
|
+
items: (await ctx.members.list())
|
|
582
|
+
.filter((member) => !member.disabled)
|
|
583
|
+
.map((member) => ({
|
|
584
|
+
principalId: member.principalId,
|
|
585
|
+
label: member.label,
|
|
586
|
+
})),
|
|
587
|
+
})),
|
|
588
|
+
orgs: r.query({
|
|
589
|
+
output: MyOrgs,
|
|
590
|
+
access: { action: 'tasks.read', over: 'org' },
|
|
591
|
+
})
|
|
592
|
+
.handle(({ ctx }) =>
|
|
593
|
+
ctx.within.values === 'all'
|
|
594
|
+
? { all: true, orgs: [] }
|
|
595
|
+
: { all: false, orgs: [...ctx.within.values].sort() }
|
|
596
|
+
),
|
|
597
|
+
invite: r.mutation({
|
|
598
|
+
input: Invite,
|
|
599
|
+
output: Invited,
|
|
600
|
+
access: { action: 'members.manage', in: 'org', from: 'orgId' },
|
|
601
|
+
})
|
|
602
|
+
.handle(async ({ input, ctx }) => {
|
|
603
|
+
const result = await ctx.members.assign(
|
|
604
|
+
ctx.target,
|
|
605
|
+
{ email: input.email },
|
|
606
|
+
input.role,
|
|
607
|
+
)
|
|
608
|
+
return {
|
|
609
|
+
invitationId: 'invited' in result
|
|
610
|
+
? result.invited.invitationId
|
|
611
|
+
: result.assigned.principalId,
|
|
612
|
+
}
|
|
613
|
+
}),
|
|
614
|
+
remove: r.mutation({
|
|
615
|
+
input: Remove,
|
|
616
|
+
output: Removed,
|
|
617
|
+
access: { action: 'members.manage', in: 'org', from: 'orgId' },
|
|
618
|
+
})
|
|
619
|
+
.handle(async ({ input, ctx }) => {
|
|
620
|
+
await ctx.members.revoke(ctx.target, { principalId: input.principalId })
|
|
621
|
+
return { ok: true }
|
|
622
|
+
}),
|
|
623
|
+
}))
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
IAM answers the directory. A lookup only reads, so it may come before the write that needs its answer (`assign` in
|
|
627
|
+
`SKILL.md`); do every other data work first.
|