@drhomet/mithaq 0.0.0-stage → 0.2.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/LICENSE +22 -0
- package/LICENSES/openstatus-time-picker.txt +21 -0
- package/LICENSES/payload.txt +22 -0
- package/LICENSES/ponytail.txt +21 -0
- package/README.md +160 -2
- package/dist/asas/actor.d.ts +96 -0
- package/dist/asas/actor.js +114 -0
- package/dist/asas/build.d.ts +239 -0
- package/dist/asas/build.js +834 -0
- package/dist/asas/cli/mithaq.d.ts +2 -0
- package/dist/asas/cli/mithaq.js +177 -0
- package/dist/asas/cli/upgrade.d.ts +33 -0
- package/dist/asas/cli/upgrade.js +256 -0
- package/dist/asas/http/fetch.d.ts +41 -0
- package/dist/asas/http/fetch.js +178 -0
- package/dist/asas/http/orpc.d.ts +45 -0
- package/dist/asas/http/orpc.js +153 -0
- package/dist/asas/runner/bulk.d.ts +20 -0
- package/dist/asas/runner/bulk.js +196 -0
- package/dist/asas/runner/errors.d.ts +63 -0
- package/dist/asas/runner/errors.js +75 -0
- package/dist/asas/runner/read.d.ts +72 -0
- package/dist/asas/runner/read.js +601 -0
- package/dist/asas/runner/runtime.d.ts +21 -0
- package/dist/asas/runner/runtime.js +8 -0
- package/dist/asas/runner/steps.d.ts +418 -0
- package/dist/asas/runner/steps.js +277 -0
- package/dist/asas/runner/store.d.ts +4 -0
- package/dist/asas/runner/store.js +21 -0
- package/dist/asas/runner/tenant.d.ts +54 -0
- package/dist/asas/runner/tenant.js +275 -0
- package/dist/asas/runner/write.d.ts +606 -0
- package/dist/asas/runner/write.js +1019 -0
- package/dist/auth/better-auth/access.d.ts +29 -0
- package/dist/auth/better-auth/access.js +103 -0
- package/dist/auth/better-auth/generate.d.ts +31 -0
- package/dist/auth/better-auth/generate.js +328 -0
- package/dist/auth/better-auth/index.d.ts +94 -0
- package/dist/auth/better-auth/index.js +81 -0
- package/dist/auth/better-auth/tenant.d.ts +26 -0
- package/dist/auth/better-auth/tenant.js +34 -0
- package/dist/auth/local/index.d.ts +285 -0
- package/dist/auth/local/index.js +738 -0
- package/dist/base/decimal.d.ts +9 -0
- package/dist/base/decimal.js +16 -0
- package/dist/base/fields.d.ts +124 -0
- package/dist/base/fields.js +52 -0
- package/dist/base/index.d.ts +47 -0
- package/dist/base/index.js +5 -0
- package/dist/base/json-schema.d.ts +7 -0
- package/dist/base/json-schema.js +35 -0
- package/dist/base/kinds.d.ts +75 -0
- package/dist/base/kinds.js +123 -0
- package/dist/base/rules.d.ts +73 -0
- package/dist/base/rules.js +81 -0
- package/dist/drizzle/index.d.ts +308 -0
- package/dist/drizzle/index.js +951 -0
- package/dist/drizzle/kinds.d.ts +28 -0
- package/dist/drizzle/kinds.js +43 -0
- package/dist/drizzle/migrate.d.ts +159 -0
- package/dist/drizzle/migrate.js +1328 -0
- package/dist/drizzle/proxy/index.d.ts +1 -0
- package/dist/drizzle/proxy/index.js +1 -0
- package/dist/drizzle/proxy/node-postgres.d.ts +1 -0
- package/dist/drizzle/proxy/node-postgres.js +1 -0
- package/dist/drizzle/proxy/pg-core.d.ts +1 -0
- package/dist/drizzle/proxy/pg-core.js +1 -0
- package/dist/drizzle/rules.d.ts +336 -0
- package/dist/drizzle/rules.js +44 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +26 -0
- package/dist/mithaq/define.d.ts +335 -0
- package/dist/mithaq/define.js +57 -0
- package/dist/mithaq/generate-messages.d.ts +38 -0
- package/dist/mithaq/generate-messages.js +235 -0
- package/dist/mithaq/language.d.ts +374 -0
- package/dist/mithaq/language.js +76 -0
- package/dist/mithaq/lock.d.ts +71 -0
- package/dist/mithaq/lock.js +329 -0
- package/dist/mithaq/message-file.d.ts +13 -0
- package/dist/mithaq/message-file.js +29 -0
- package/dist/mithaq/messages.d.ts +55 -0
- package/dist/mithaq/messages.js +111 -0
- package/dist/mithaq/refusal.d.ts +6 -0
- package/dist/mithaq/refusal.js +6 -0
- package/dist/mithaq/resolve.d.ts +10 -0
- package/dist/mithaq/resolve.js +1014 -0
- package/dist/mithaq/rows.d.ts +87 -0
- package/dist/mithaq/rows.js +174 -0
- package/dist/mithaq/rukn.d.ts +72 -0
- package/dist/mithaq/rukn.js +15 -0
- package/dist/mithaq/standard-schema.d.ts +38 -0
- package/dist/mithaq/standard-schema.js +2 -0
- package/dist/mithaq/types.d.ts +386 -0
- package/dist/mithaq/types.js +15 -0
- package/dist/mithaq/user.d.ts +19 -0
- package/dist/mithaq/user.js +16 -0
- package/dist/mithaq/where.d.ts +32 -0
- package/dist/mithaq/where.js +87 -0
- package/dist/rbac/index.d.ts +105 -0
- package/dist/rbac/index.js +182 -0
- package/dist/rbac/steps.d.ts +22 -0
- package/dist/rbac/steps.js +161 -0
- package/dist/rich-text/index.d.ts +28 -0
- package/dist/rich-text/index.js +59 -0
- package/dist/rich-text/rich-text.d.ts +106 -0
- package/dist/rich-text/rich-text.js +249 -0
- package/dist/rich-text/value.d.ts +55 -0
- package/dist/rich-text/value.js +126 -0
- package/dist/screen/client.d.ts +156 -0
- package/dist/screen/client.js +71 -0
- package/dist/screen/generate.d.ts +14 -0
- package/dist/screen/generate.js +268 -0
- package/dist/screen/index.d.ts +496 -0
- package/dist/screen/index.js +663 -0
- package/dist/screen/react.d.ts +97 -0
- package/dist/screen/react.js +58 -0
- package/dist/screen/studio.d.ts +24 -0
- package/dist/screen/studio.js +68 -0
- package/dist/service/index.d.ts +63 -0
- package/dist/service/index.js +15 -0
- package/dist/storage/allowed-type.d.ts +5 -0
- package/dist/storage/allowed-type.js +7 -0
- package/dist/storage/index.d.ts +42 -0
- package/dist/storage/index.js +43 -0
- package/dist/storage/storage.d.ts +386 -0
- package/dist/storage/storage.js +412 -0
- package/dist/targets.d.ts +14 -0
- package/dist/targets.js +16 -0
- package/dist/versions/index.d.ts +37 -0
- package/dist/versions/index.js +8 -0
- package/dist/versions/versions.d.ts +104 -0
- package/dist/versions/versions.js +160 -0
- package/dist/vite.d.ts +7 -0
- package/dist/vite.js +81 -0
- package/dist/zod/index.d.ts +193 -0
- package/dist/zod/index.js +122 -0
- package/dist/zod/kinds.d.ts +36 -0
- package/dist/zod/kinds.js +58 -0
- package/dist/zod/rules.d.ts +117 -0
- package/dist/zod/rules.js +67 -0
- package/docs/packages/mithaq/README.md +962 -0
- package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
- package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
- package/docs/packages/mithaq/src/drizzle/README.md +39 -0
- package/docs/packages/mithaq-admin/README.md +312 -0
- package/docs/templates/asas/README.md +1056 -0
- package/package.json +92 -4
- package/skills/add-field/SKILL.md +27 -0
- package/skills/add-kayan/SKILL.md +26 -0
- package/skills/eject-admin-piece/SKILL.md +28 -0
- package/skills/upgrade-mithaq/SKILL.md +35 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
# Tenancy
|
|
2
|
+
|
|
3
|
+
mithaq keeps each organization's rows apart. A kayan (an entity or collection)
|
|
4
|
+
whose fields include the organization field belongs to one organization per
|
|
5
|
+
row, and core holds every managed operation on it to the organization the
|
|
6
|
+
caller's session has active. A kayan without that field is shared by every
|
|
7
|
+
organization.
|
|
8
|
+
|
|
9
|
+
The organizations are Better Auth's: the `organization` kayan of the
|
|
10
|
+
provider's organization plugin, with its `member` rows. This page covers the
|
|
11
|
+
setup, what core enforces and why it cannot be turned off, how a read reaches
|
|
12
|
+
further, and what is refused. Read [the language reference](../../../README.md) first if the
|
|
13
|
+
words kayan, rukn, asas, target or operation are new.
|
|
14
|
+
|
|
15
|
+
## Setup
|
|
16
|
+
|
|
17
|
+
1. **Lay down the tenant target** in `defineMithaq`'s `targets`, under the key
|
|
18
|
+
`tenant`:
|
|
19
|
+
|
|
20
|
+
<!-- from: templates/mithaq/src/mithaq.ts -->
|
|
21
|
+
```ts
|
|
22
|
+
tenant: betterAuthTenant({
|
|
23
|
+
relationTo: 'organization',
|
|
24
|
+
onDelete: 'restrict',
|
|
25
|
+
newRows: 'active',
|
|
26
|
+
organizations,
|
|
27
|
+
backoffice: ({ user }) => onBackoffice() && backofficeRolesOf(user).length > 0,
|
|
28
|
+
}),
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
`relationTo` names the kayan an organization is. `onDelete: 'restrict'` is
|
|
32
|
+
the only value it takes: an organization that still holds a row is not
|
|
33
|
+
deleted. `organizations` is required; see [Organization modes](#organization-modes). It is
|
|
34
|
+
new: a language that lays down `betterAuthTenant` without it is refused, so a project made
|
|
35
|
+
before it adds `organizations: 'many'`, the behaviour it had.
|
|
36
|
+
`backoffice` is optional; see [The backoffice app](#the-backoffice-app).
|
|
37
|
+
|
|
38
|
+
2. **Export the field.** The language returns the target as `targets: {
|
|
39
|
+
tenant }`, and its `field` is what a kayan places. The template exports a
|
|
40
|
+
labelled copy, under its own word for a tenant:
|
|
41
|
+
|
|
42
|
+
<!-- from: templates/mithaq/src/mithaq.ts -->
|
|
43
|
+
```ts
|
|
44
|
+
export const { defineKayan, defineGlobal, buildAsas, rows, user, generate, locales, targets, targets: { tenant } } = language
|
|
45
|
+
|
|
46
|
+
export const organization = { ...tenant.field, label: { en: 'Organization', ar: 'المؤسسة' } }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
3. **Build with one Better Auth kayan.** The provider's kayan (`user`,
|
|
50
|
+
`organization`, `member` and the rest, written by `mithaq generate auth`)
|
|
51
|
+
go in the `buildAsas` kayan list. A build with any per-organization kayan
|
|
52
|
+
carries one Better Auth kayan, and a second is refused, because its sessions
|
|
53
|
+
name the organizations the field points at.
|
|
54
|
+
|
|
55
|
+
4. **Lay down an access target** for roles per organization. The template's
|
|
56
|
+
`rbacTarget` reads each member's roles from `memberRoles` and each
|
|
57
|
+
organization's custom roles from `roles`, and both kayan place
|
|
58
|
+
`organization`. `tenant: 'mine'` needs it.
|
|
59
|
+
|
|
60
|
+
5. **Place the field** in each kayan that belongs to an organization, then run
|
|
61
|
+
`mithaq migrate` to add the column.
|
|
62
|
+
|
|
63
|
+
Where it lives: `packages/mithaq/src/auth/better-auth/tenant.ts`
|
|
64
|
+
`betterAuthTenant`; the template's setup is `templates/mithaq/src/mithaq.ts`
|
|
65
|
+
`serverTargets`.
|
|
66
|
+
|
|
67
|
+
## The field and its binding
|
|
68
|
+
|
|
69
|
+
A kayan is per-organization when it carries a field bound to the tenant target.
|
|
70
|
+
The binding is the field's `targets: { tenant: true }`, which `tenant.field`
|
|
71
|
+
already carries:
|
|
72
|
+
|
|
73
|
+
<!-- from: templates/features/tags/tags/tags.ts -->
|
|
74
|
+
```ts
|
|
75
|
+
export const tags = defineKayan({
|
|
76
|
+
name: 'tags',
|
|
77
|
+
// ...
|
|
78
|
+
fields: {
|
|
79
|
+
...defaultFields,
|
|
80
|
+
organization,
|
|
81
|
+
name: {
|
|
82
|
+
kind: 'text',
|
|
83
|
+
label: { en: 'Name', ar: 'الاسم' },
|
|
84
|
+
rules: { localized: true, required: true, trim: true, maxLength: 60, index: true },
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`tenant.field` is a relationship to one `organization` row with these rules:
|
|
91
|
+
`required`, `index`, `providedBy: { create: 'application', update: 'never' }`
|
|
92
|
+
and `onDelete: 'restrict'`. Its column is named after the field,
|
|
93
|
+
`organization_id` for a field called `organization`, with a foreign key that
|
|
94
|
+
restricts.
|
|
95
|
+
|
|
96
|
+
A field written by hand works too. It takes every rule of the target's field
|
|
97
|
+
that it leaves out:
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
organization: {
|
|
101
|
+
kind: 'relationship',
|
|
102
|
+
relationTo: 'organization',
|
|
103
|
+
label: { en: 'Organization', ar: 'المؤسسة' },
|
|
104
|
+
targets: { tenant: true },
|
|
105
|
+
},
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
A bound field may change its copy (label, description), add `unique`, and say
|
|
109
|
+
how a screen draws it under `targets.<screen>`. Nothing else about it can
|
|
110
|
+
change; see [What is refused](#what-is-refused).
|
|
111
|
+
|
|
112
|
+
A kayan that places no bound field is shared: every organization reads it
|
|
113
|
+
through its own access, and its rows belong to none. There is no per-kayan
|
|
114
|
+
opt-out and no list of per-organization kayan: the field alone decides. (A
|
|
115
|
+
kayan's `access: { tenant: false }` drops the row table it would inherit from
|
|
116
|
+
the access target. It does not touch the organization scope.)
|
|
117
|
+
|
|
118
|
+
## What core enforces
|
|
119
|
+
|
|
120
|
+
On every managed operation of a per-organization kayan, from the HTTP plugs,
|
|
121
|
+
from `asas.kayan.<name>.operations` in the app, and from a step's nested call,
|
|
122
|
+
core does all of this:
|
|
123
|
+
|
|
124
|
+
- **The scope is the session's active organization.** It is the
|
|
125
|
+
`activeOrganizationId` of the actor's Better Auth session, from the build's
|
|
126
|
+
one Better Auth kayan, and it counts only when the provider lists the actor as
|
|
127
|
+
a member of that organization. Anything else has no scope: an anonymous
|
|
128
|
+
caller, a local-auth account, a session with no active organization, a
|
|
129
|
+
session naming an organization the actor is not a member of, and a session of
|
|
130
|
+
another Better Auth instance. Each is refused with a 403 before any step runs.
|
|
131
|
+
It fails closed: no scope never means no filter.
|
|
132
|
+
- **A create is filled from the scope.** The runner writes the field after the
|
|
133
|
+
parse, from the session. A caller who sends the field is refused with a 400
|
|
134
|
+
(`Unrecognized key`) before any step runs.
|
|
135
|
+
- **Reads, updates and deletes are ANDed with `organization = active`**, on
|
|
136
|
+
top of whatever the kayan's access answers. This covers `find`, `findByID`,
|
|
137
|
+
`count`, the row an update or delete loads, a bulk write's matched rows, a
|
|
138
|
+
filter across a relationship to a per-organization kayan, and every level of
|
|
139
|
+
`join` population.
|
|
140
|
+
- **Another organization's row answers like a missing one.** A read by id, an
|
|
141
|
+
update and a delete of it are the same 404 (`error.not_found.row`) an absent
|
|
142
|
+
id gets, so the answer never says the id exists.
|
|
143
|
+
- **A delete's refusal counts the organization's own rows.** A row that any
|
|
144
|
+
organization's rows or latest drafts still name is not deleted. The refusal
|
|
145
|
+
counts and names those of the deleted row's organization, or of the actor's
|
|
146
|
+
active one for a shared row; a shared row that another organization's rows
|
|
147
|
+
name is refused with no count, so the answer never says how much of another
|
|
148
|
+
organization uses it.
|
|
149
|
+
- **The field never changes.** It is `providedBy: { update: 'never' }`. A
|
|
150
|
+
step that writes it on an update, or writes another organization into it on a
|
|
151
|
+
create, fails, so nothing moves a row to another organization.
|
|
152
|
+
- **References stay in the organization.** Every relationship and upload
|
|
153
|
+
value a write names, single or `hasMany`, must point at a row the actor may
|
|
154
|
+
read, and one that points at a per-organization kayan must name a row of the
|
|
155
|
+
written row's organization. Another organization's row is refused with the
|
|
156
|
+
same 400 an absent id gets.
|
|
157
|
+
- **Versions, uploads and globals are scoped.** A version is read and restored
|
|
158
|
+
through its row's own access, and a snapshot never moves its row to another
|
|
159
|
+
organization. An upload is signed for the active organization and served
|
|
160
|
+
through the row's read. A global that places the field is one row per
|
|
161
|
+
organization.
|
|
162
|
+
- **A step's raw store is sealed.** `tools.store.find`, `hold`, `sum`, `upsert`,
|
|
163
|
+
`update` and `remove` are ANDed with `organization = active`. `insert` and
|
|
164
|
+
`upsert` fill the active organization and refuse any other or no active
|
|
165
|
+
organization. `update` and `remove` may not name the field.
|
|
166
|
+
A relationship value a raw write names must point at the active
|
|
167
|
+
organization's row. This holds whichever per-organization kayan the step
|
|
168
|
+
names, not only the operation's own. A raw write reaches exactly the id it
|
|
169
|
+
names; only the operation's own row takes its access, versions and soft
|
|
170
|
+
delete. A raw write names a kayan; a versions or join table is core's and
|
|
171
|
+
is refused. `upsert` requires plain key values for exactly a declared unique
|
|
172
|
+
index in `on`; `on: ['id']` works without an extra index. Upserting the
|
|
173
|
+
operation's own versioned kayan while saving a draft is refused. An update
|
|
174
|
+
of the operation's own row refuses arithmetic in a draft snapshot.
|
|
175
|
+
|
|
176
|
+
`ctx.tx` is the store's transaction itself. Raw SQL through it is the
|
|
177
|
+
project's own privileged path, which core does not scope; see
|
|
178
|
+
[Privileged paths](#privileged-paths).
|
|
179
|
+
|
|
180
|
+
### Why it cannot be bypassed
|
|
181
|
+
|
|
182
|
+
- The scope wraps the build's access evaluator, whichever one the service
|
|
183
|
+
target names, and it is asked before the project's own callback. A row table,
|
|
184
|
+
an access callback answering `true`, a custom `authorize` or a replaced step
|
|
185
|
+
list can narrow what an operation reaches and never widen it.
|
|
186
|
+
- The organization comes from the trusted snapshot the auth strategy took of
|
|
187
|
+
the session, not from the actor object. Changing `activeOrganizationId` on
|
|
188
|
+
the object moves nothing.
|
|
189
|
+
- The only store a step is handed is the sealed one. `ctx.tx` is the raw way
|
|
190
|
+
past it, and that is the project's own.
|
|
191
|
+
- A nested managed call runs under the scope of the call around it, and a read's
|
|
192
|
+
reach is opened by the outermost read alone, so a call inside it cannot widen
|
|
193
|
+
what it reaches.
|
|
194
|
+
|
|
195
|
+
Where it lives: `packages/mithaq/src/asas/runner/tenant.ts` `tenancy` (with
|
|
196
|
+
`scoped`, `sealWrites` and `activeTenant`), `writeTenant` there, and
|
|
197
|
+
`packages/mithaq/src/asas/runner/write.ts` `assertReferences`. The tests are
|
|
198
|
+
`templates/asas/src/tenant-scope.test.ts` and
|
|
199
|
+
`templates/asas/src/tenant-target.test.ts`.
|
|
200
|
+
|
|
201
|
+
## Reading further: `tenant: 'mine' | 'all'`
|
|
202
|
+
|
|
203
|
+
A read may ask to reach past the active organization with the `tenant` option.
|
|
204
|
+
In the app it is the second argument; over HTTP it is `?tenant=` on a read
|
|
205
|
+
route.
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
const page = await asas.kayan.courses.operations.find({ limit: 20 }, { actor, tenant: 'mine' })
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
POST /api/courses/find?tenant=mine
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
- **`'mine'`** reads every organization the actor is a verified member of. Each
|
|
216
|
+
membership is judged under the roles the actor holds in that organization: a
|
|
217
|
+
membership whose roles may not read the kayan adds nothing, and each row comes
|
|
218
|
+
back cut to the fields that membership may read. A filter or a sort on a field
|
|
219
|
+
any reached membership may not read is a 403. A membership whose roles fail to
|
|
220
|
+
resolve holds no key, adds nothing, and the failure goes to `auth.onError`.
|
|
221
|
+
It needs a Better Auth session and an access target; without either it is a
|
|
222
|
+
403, and so is a read no membership may make.
|
|
223
|
+
- **`'all'`** reads every organization. Only backoffice staff in the trusted
|
|
224
|
+
backoffice app may ask it: the actor must be an authenticated one and the
|
|
225
|
+
target's `backoffice` callback must answer `true`. Anyone else gets a 403.
|
|
226
|
+
|
|
227
|
+
Both are for reads alone:
|
|
228
|
+
|
|
229
|
+
- A `where` on the organization field only narrows: `{ organization: { equals:
|
|
230
|
+
'org2' } }` reaches org2 only where the reach already includes it.
|
|
231
|
+
- A write always lands in the active organization. A `tenant` option on a
|
|
232
|
+
create, update, delete or bulk write is a 400 (`error.invalid.tenant_option`).
|
|
233
|
+
- A global refuses the option with the same 400: its read is the active
|
|
234
|
+
organization's row.
|
|
235
|
+
- So does a build with no tenant target, and any value other than `'mine'` or
|
|
236
|
+
`'all'`.
|
|
237
|
+
|
|
238
|
+
## The backoffice app
|
|
239
|
+
|
|
240
|
+
`'all'` is meant for an app that serves backoffice staff and nothing else. The
|
|
241
|
+
`backoffice` callback decides who that is, and the project decides which app is
|
|
242
|
+
trusted: the answer must come from the app's own facts, never from the request.
|
|
243
|
+
|
|
244
|
+
The template does it like this:
|
|
245
|
+
|
|
246
|
+
- `apps/asas-backoffice` runs every request it serves inside `asBackoffice`, an
|
|
247
|
+
`AsyncLocalStorage` that nothing a client sends can set. `onBackoffice()`
|
|
248
|
+
answers `true` there and nowhere else.
|
|
249
|
+
- Backoffice roles (`super_admin`, `support`, `explorer`) live on Better Auth's
|
|
250
|
+
`user.role`, never in an organization's `memberRoles`. The template's
|
|
251
|
+
`assignedRoles` answers them in the backoffice app alone, and the tenant app
|
|
252
|
+
never answers them.
|
|
253
|
+
- `backoffice: ({ user }) => onBackoffice() && backofficeRolesOf(user).length > 0`.
|
|
254
|
+
- The backoffice screen's reads of a kayan pass `tenant: 'all'`; a global's read
|
|
255
|
+
and every write pass nothing.
|
|
256
|
+
- The rows give every backoffice role `read: true` and `false` for every write,
|
|
257
|
+
so staff read every organization's rows and write none.
|
|
258
|
+
|
|
259
|
+
Where it lives: `templates/mithaq/src/mithaq/auth/index.ts` `asBackoffice` and `onBackoffice`;
|
|
260
|
+
`templates/mithaq/src/roles/tenant-access.ts` `assignedRoles` and `backofficeReads`;
|
|
261
|
+
`apps/asas-backoffice/src/screens.tsx` `inProcess`.
|
|
262
|
+
|
|
263
|
+
## Organization modes
|
|
264
|
+
|
|
265
|
+
`organizations` tells core how many organizations the project holds:
|
|
266
|
+
|
|
267
|
+
- `'many'`: any number.
|
|
268
|
+
- `'one'`: one. Whether the project shows it by name is the project's own setting, which core never reads.
|
|
269
|
+
|
|
270
|
+
Tenancy is the same in both: every per-organization kayan places the field, and the scope above is ANDed onto every managed operation. So switching between them moves no row.
|
|
271
|
+
|
|
272
|
+
What core does with the value: in `'one'`, `connect()` reads the tenant kayan after the stores connect, and refuses to start when it holds more than one row. The message names the mode. A `connect({ readOnly: true })` does not check.
|
|
273
|
+
|
|
274
|
+
Two organizations in a one-organization project would split its people and rows without a word. Leaving `'many'` therefore needs a merge first: re-key the field on every per-organization table and resolve what each organization held once (a `within` unique, a global's row, roles). That merge is a migration of the project's own.
|
|
275
|
+
|
|
276
|
+
What the project does with the value: sign-up, joining and the screens follow it in the template's auth setup (`templates/mithaq/src/mithaq/auth/organizations.ts`). Core reads nothing there.
|
|
277
|
+
|
|
278
|
+
## Unique values and indexes
|
|
279
|
+
|
|
280
|
+
On a per-organization kayan, a field's `unique: true` means unique within the
|
|
281
|
+
organization. Its default index is `(organization, field)`, so another
|
|
282
|
+
organization may hold the same value and never learns it is taken. On a shared
|
|
283
|
+
kayan the same rule is unique across the table.
|
|
284
|
+
|
|
285
|
+
A second row with a value the organization already holds is a 409
|
|
286
|
+
(`error.conflict.unique`) whose message and params name no value.
|
|
287
|
+
|
|
288
|
+
A kayan's `indexes` is a list, or a function of the defaults:
|
|
289
|
+
|
|
290
|
+
- Left out, the kayan gets the defaults: one unique index per `unique` field.
|
|
291
|
+
- A list is added to the defaults.
|
|
292
|
+
- `({ defaults }) => [...]` answers the whole list, so it can drop or replace a
|
|
293
|
+
default.
|
|
294
|
+
|
|
295
|
+
A group expands to indexes led by the organization field:
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
indexes: [{ within: 'organization', unique: ['code', ['code', 'term']], index: ['startsAt'] }],
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
This builds unique `(organization, code)`, unique `(organization, code, term)`
|
|
302
|
+
and a plain index `(organization, startsAt)`. Each entry of `unique` and
|
|
303
|
+
`index` is a field or a list of fields. `within` must name the kayan's bound
|
|
304
|
+
field, and a group takes `within`, `unique` and `index` alone and names at
|
|
305
|
+
least one index.
|
|
306
|
+
|
|
307
|
+
A plain entry means exactly what it says. `{ fields: ['code'], unique: true }`
|
|
308
|
+
on a per-organization kayan is unique across every organization, on purpose:
|
|
309
|
+
use it for a value that must be unique everywhere.
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
indexes: ({ defaults }) => [
|
|
313
|
+
...defaults.filter((index) => !index.fields.includes('code')),
|
|
314
|
+
{ fields: ['code'], unique: true },
|
|
315
|
+
],
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Two more rules hold:
|
|
319
|
+
|
|
320
|
+
- A `unique` field that no unique index covers is refused, so dropping a
|
|
321
|
+
default without replacing it fails the build.
|
|
322
|
+
- A file kayan's storage `key` stays unique across the table, even on a
|
|
323
|
+
per-organization kayan: a key is the address of one object in a store every
|
|
324
|
+
organization shares.
|
|
325
|
+
|
|
326
|
+
Where it lives: `packages/mithaq/src/mithaq/resolve.ts`, the `indexes` handling
|
|
327
|
+
after `uniqueRuleIndexes`.
|
|
328
|
+
|
|
329
|
+
## Storage
|
|
330
|
+
|
|
331
|
+
A per-organization kayan that holds files keeps each organization's files
|
|
332
|
+
apart:
|
|
333
|
+
|
|
334
|
+
- `POST /api/<kayan>/uploadInstructions` asks the create access of the kayan
|
|
335
|
+
under the scope, so an actor with no active organization is refused with a
|
|
336
|
+
403.
|
|
337
|
+
- The upload reference is signed with the active organization. A create that
|
|
338
|
+
sends a reference signed for another organization, another kayan or another
|
|
339
|
+
hour is refused with a 400 (`error.invalid.upload_reference`).
|
|
340
|
+
- The storage config's `key` function is handed the organization. The
|
|
341
|
+
template's `defaultKey` writes `<prefix>/<organization>/<kayan>/<id>/<filename>`,
|
|
342
|
+
and `_` in place of the organization for a shared kayan.
|
|
343
|
+
- Every key must hold the row id as a whole path segment, and sit under the
|
|
344
|
+
adapter's prefix where it has one, whatever the project's `key` function
|
|
345
|
+
answers.
|
|
346
|
+
- `GET /api/<kayan>/file/<id>` reads the row through the kayan's own
|
|
347
|
+
`findByID` as the actor, so another organization's file answers the same 404
|
|
348
|
+
as an absent one.
|
|
349
|
+
- `public: true` is refused on a per-organization file kayan: a public address
|
|
350
|
+
would hand one organization's file to anyone who has it.
|
|
351
|
+
|
|
352
|
+
A shared kayan's files belong to no organization, whichever session uploads
|
|
353
|
+
them.
|
|
354
|
+
|
|
355
|
+
Where it lives: `packages/mithaq/src/storage/storage.ts` `signReference`,
|
|
356
|
+
`checkReference` and `resolveStorage`; `templates/mithaq/src/storage/defaults.ts`
|
|
357
|
+
`defaultKey`.
|
|
358
|
+
|
|
359
|
+
## Globals
|
|
360
|
+
|
|
361
|
+
A global that places the bound field is one row per organization. Write the
|
|
362
|
+
field `unique` and `globalType` not unique:
|
|
363
|
+
|
|
364
|
+
<!-- from: templates/features/settings/settings/settings.ts -->
|
|
365
|
+
```ts
|
|
366
|
+
export const settings = defineGlobal({
|
|
367
|
+
name: 'settings',
|
|
368
|
+
fields: {
|
|
369
|
+
...defaultFields,
|
|
370
|
+
organization: { ...organization, rules: { ...organization.rules, unique: true } },
|
|
371
|
+
// ...
|
|
372
|
+
contactEmail: { kind: 'email', label: { en: 'Contact email', ar: 'بريد التواصل' } },
|
|
373
|
+
globalType: { ...defaultGlobalFields.globalType, rules: { required: true, defaultValue: 'settings' } },
|
|
374
|
+
},
|
|
375
|
+
// ...
|
|
376
|
+
targets: {
|
|
377
|
+
service: { operations: defaultGlobalOperations },
|
|
378
|
+
// ...
|
|
379
|
+
},
|
|
380
|
+
})
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
- `findOne({}, { actor })` answers the active organization's row, or `null`
|
|
384
|
+
before its first write.
|
|
385
|
+
- `update(data, { actor })` writes it, and the first update makes it.
|
|
386
|
+
- Two concurrent first writes of one organization make one row; the other is
|
|
387
|
+
the 409 a unique value gets.
|
|
388
|
+
- With no active organization both are a 403, and a `tenant` option is a 400.
|
|
389
|
+
|
|
390
|
+
`buildAsas` refuses the field without `unique`, a `unique` `globalType`, and a
|
|
391
|
+
global that drops the unique index over the field alone. The template's global
|
|
392
|
+
is `templates/features/settings/settings/settings.ts` `settings`.
|
|
393
|
+
|
|
394
|
+
## What is refused
|
|
395
|
+
|
|
396
|
+
`buildAsas`, or the target itself, refuses each of these with a message that
|
|
397
|
+
names the fix:
|
|
398
|
+
|
|
399
|
+
- `onDelete` other than `'restrict'`. `cascade` would delete rows in the
|
|
400
|
+
database, where no step runs and no file or audit follows them; `set null`
|
|
401
|
+
would leave rows no organization owns or can read.
|
|
402
|
+
- Two bound fields in one kayan: a row belongs to one organization.
|
|
403
|
+
- A bound field where the language lays down no tenant target.
|
|
404
|
+
- A tenant target under any key but `tenant`, or another capability under
|
|
405
|
+
`tenant`.
|
|
406
|
+
- A kayan that writes `targets.tenant` itself: the field decides, not the
|
|
407
|
+
kayan.
|
|
408
|
+
- A store or schema target's entry on the bound field, such as
|
|
409
|
+
`targets.drizzle`, which would build the column in place of the tenant's
|
|
410
|
+
rules. A screen's entry is the only other one it takes.
|
|
411
|
+
- A bound field that loosens the binding: `hasMany`, another `relationTo`, a
|
|
412
|
+
missing `required` or `index`, another `providedBy`, another `onDelete`, a
|
|
413
|
+
`defaultValue`, or `targets.tenant` set to anything but `true`.
|
|
414
|
+
- A field that looks like the tenant field but is unbound: a relationship to
|
|
415
|
+
the organization kayan filled by the application on create and never after,
|
|
416
|
+
with no `targets.tenant`. This is what a spread of `tenant.field` with its own
|
|
417
|
+
`targets` produces, and it would leave the kayan shared.
|
|
418
|
+
- A per-organization kayan with `auth`: an account is one identity across every
|
|
419
|
+
organization, found by its email and tokens alone.
|
|
420
|
+
- More than one Better Auth kayan in a build with any per-organization kayan.
|
|
421
|
+
- `storage.public: true` on a per-organization file kayan.
|
|
422
|
+
- A per-organization global whose field is not `unique`, whose `globalType` is
|
|
423
|
+
`unique`, or that drops the unique index over the field.
|
|
424
|
+
- A `within` group on a shared kayan, or within any field but the bound one.
|
|
425
|
+
|
|
426
|
+
At run time:
|
|
427
|
+
|
|
428
|
+
- A `tenant` option on a write or a global, or a value other than `'mine'` or
|
|
429
|
+
`'all'`: 400.
|
|
430
|
+
- The bound field sent by a caller: 400.
|
|
431
|
+
- An actor with no verified active organization: 403.
|
|
432
|
+
- `'all'` from anyone but backoffice staff in the backoffice app: 403.
|
|
433
|
+
|
|
434
|
+
## What is not supported
|
|
435
|
+
|
|
436
|
+
- **Public sites per organization.** An anonymous caller has no organization, so
|
|
437
|
+
every operation of a per-organization kayan refuses it. A public page reads a
|
|
438
|
+
shared kayan, or reads through the project's own server code.
|
|
439
|
+
- **Writes across organizations.** A write lands in the active organization
|
|
440
|
+
alone. To act in another organization, switch the session's active
|
|
441
|
+
organization first.
|
|
442
|
+
|
|
443
|
+
## Privileged paths
|
|
444
|
+
|
|
445
|
+
Core scopes managed operations. These paths are not managed operations, and
|
|
446
|
+
whoever writes them scopes them:
|
|
447
|
+
|
|
448
|
+
- **Raw SQL through `ctx.tx`.** A step's `ctx.tx` is the store's own
|
|
449
|
+
transaction, and a query through it sees every organization. Name the
|
|
450
|
+
organization in the query yourself. The template's
|
|
451
|
+
`templates/features/roles/member-roles/member-roles.ts` `managerRemains` does it:
|
|
452
|
+
|
|
453
|
+
```ts
|
|
454
|
+
const tx = ctx.tx as NodePgDatabase
|
|
455
|
+
await tx.execute(sql`select id, user_id as "user", role from member_roles where organization_id = ${tenant}`)
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Prefer a managed call, `tools.of(kayan)`, wherever one does the job: it
|
|
459
|
+
carries the scope, the access and the transaction.
|
|
460
|
+
- **The store outside any operation**, such as `asas.targets.drizzle.db`. It
|
|
461
|
+
reads and writes every organization.
|
|
462
|
+
- **Seeds.** The template's `templates/asas/src/seed.ts` writes every row
|
|
463
|
+
through the build's own operations, with a real session whose active
|
|
464
|
+
organization is the one it fills, so the scope applies as it does to a person.
|
|
465
|
+
The few raw updates it makes name `organization_id` themselves.
|
|
466
|
+
- **Migrations.** `mithaq migrate` writes the migrations, and applying them
|
|
467
|
+
runs outside the runner. A migration that moves data names the organization
|
|
468
|
+
itself.
|
|
469
|
+
- **Commands.** `mithaq rbac assign --tenant <id> --user <id> --role <slug>`
|
|
470
|
+
writes one assignment directly, after checking the user is a member of that
|
|
471
|
+
organization. `mithaq storage sweep` reads every row of every file kayan to
|
|
472
|
+
find objects no row names.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Migrations in production
|
|
2
|
+
|
|
3
|
+
In production, `connect()` on the asas, the runtime `buildAsas` builds, does not push the schema. It checks the migrations instead, as `prodMigrations` on `drizzleAdapter` says. This page covers what its two settings do, the lock every writer of the migration history takes, and applying migrations by hand. Setting `prodMigrations` in a project is in [`connect()` at start](../../../../templates/asas/README.md#connect-at-start).
|
|
4
|
+
|
|
5
|
+
## `pending: 'refuse'` or `'run'`
|
|
6
|
+
|
|
7
|
+
| | `'refuse'` (the template's choice) | `'run'` |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| A migration is pending | The app refuses to start, names the pending folders, and exits 1. Nothing is served and nothing is written. | The app applies every pending migration in one transaction, then serves. If one fails, none is applied. |
|
|
10
|
+
| Who applies migrations | Your deploy, as a separate step before the apps start. | The first app instance that starts. |
|
|
11
|
+
| Use it when | You want schema changes to be a deliberate, visible step, reviewed or run by a person or a pipeline. | You deploy one artifact and want it to bring its own schema, and you accept that starting the app changes the database. |
|
|
12
|
+
|
|
13
|
+
With either choice, a production start also refuses:
|
|
14
|
+
|
|
15
|
+
- a database with the dev-push marker;
|
|
16
|
+
- a database whose history is still in drizzle's default table, `drizzle.__drizzle_migrations`. Run `mithaq migrate` against it once to move the history, then start again.
|
|
17
|
+
|
|
18
|
+
## The advisory lock
|
|
19
|
+
|
|
20
|
+
Every writer of the migration history takes one Postgres advisory lock on `drizzle.__mithaq_migrations` (`pg_advisory_xact_lock`). This covers a production start with `pending: 'run'`, a development push, and the step that creates the history table. If several instances start at once with `'run'`, one applies the migrations and the others wait, then find nothing left to apply.
|
|
21
|
+
|
|
22
|
+
## Applying migrations by hand
|
|
23
|
+
|
|
24
|
+
With `pending: 'refuse'`, you apply migrations with drizzle-kit, using the version the installed mithaq depends on, and the config `mithaq migrate` wrote:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
cd templates/asas
|
|
28
|
+
set -a; . ./.env; set +a # or export DATABASE_URL from your secret store
|
|
29
|
+
DRIZZLE_KIT=$(node -p "path.join(path.dirname(require('module').createRequire(fs.realpathSync('node_modules/mithaq/package.json')).resolve('drizzle-kit')), 'bin.cjs')")
|
|
30
|
+
node "$DRIZZLE_KIT" migrate --config src/migrations/drizzle.config.ts
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The config reads `DATABASE_URL` through the project's own `asas`, so the migration reaches the same database the app uses.
|
|
34
|
+
|
|
35
|
+
> **Warning:** `drizzle-kit migrate` takes no lock. Do not run it while an app is starting, and never while an instance configured with `pending: 'run'` may start, or both can apply the same migration. Run it, wait for it to finish, then start the apps.
|
|
36
|
+
|
|
37
|
+
If a migration's SQL was already run by hand, record it instead of applying it again. `mithaq migrate` prints the exact `insert` line for each unapplied folder. Record only a migration whose SQL really ran.
|
|
38
|
+
|
|
39
|
+
> ponytail: a migration builds an index with a plain `CREATE INDEX`, which blocks writes to its table while it runs, such as the indexes Better Auth asks for on `session`, `account`, `verification`, `member` and `invitation`. On a table of millions of rows, build each one by hand first with `CREATE INDEX CONCURRENTLY`, under the exact name the migration gives it, then record that migration as above instead of applying it.
|