cvx-kit 0.0.2 → 0.0.3
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 +1 -0
- package/dist/auth.d.mts +52 -5
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/foundation/client.d.mts +40 -3
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +1 -1
- package/dist/tenancy.d.mts +34 -0
- package/dist/tenancy.mjs +2 -0
- package/dist/tenancy.mjs.map +1 -0
- package/dist/triggers.d.mts +9 -1
- package/dist/triggers.mjs +1 -1
- package/dist/triggers.mjs.map +1 -1
- package/dist/zod-table.d.mts +21 -1
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/auth.md +10 -0
- package/docs/commands.md +56 -4
- package/docs/conventions.md +21 -0
- package/docs/explain/modules-and-multitenancy.html +469 -0
- package/docs/llms.md +16 -4
- package/docs/tenancy.md +179 -0
- package/docs/triggers.md +5 -0
- package/docs/zod-table.md +23 -0
- package/package.json +5 -1
- package/src/auth.ts +127 -14
- package/src/components/foundation/client.ts +114 -3
- package/src/index.ts +15 -0
- package/src/tenancy.ts +110 -0
- package/src/triggers.ts +34 -0
- package/src/zod-table.ts +86 -0
- /package/docs/{convex-architecture-explainer.html → explain/convex-architecture-explainer.html} +0 -0
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Modules, not entities — and tenancy as a structural feature of cvx-kit</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #0f1115; --panel: #171a21; --panel2: #1d212b; --ink: #e6e9ef;
|
|
10
|
+
--muted: #9aa3b2; --accent: #7aa2f7; --good: #9ece6a; --warn: #e0af68;
|
|
11
|
+
--bad: #f7768e; --code-bg: #12141a; --border: #2a2f3a;
|
|
12
|
+
}
|
|
13
|
+
html { background: var(--bg); }
|
|
14
|
+
body {
|
|
15
|
+
color: var(--ink); margin: 0; padding: 2rem 1.25rem 4rem;
|
|
16
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
17
|
+
line-height: 1.55;
|
|
18
|
+
}
|
|
19
|
+
main { max-width: 72ch; margin: 0 auto; }
|
|
20
|
+
.wide { max-width: 100ch; margin-left: auto; margin-right: auto; }
|
|
21
|
+
h1 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 .75rem; }
|
|
22
|
+
h2 { font-size: 1.2rem; margin: 2.5rem 0 .5rem; color: var(--accent); }
|
|
23
|
+
h3 { font-size: 1rem; margin: 1.75rem 0 .4rem; }
|
|
24
|
+
p, li { font-size: .95rem; }
|
|
25
|
+
dl.meta {
|
|
26
|
+
display: grid; grid-template-columns: max-content 1fr; gap: .15rem .9rem;
|
|
27
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
|
28
|
+
padding: .75rem 1rem; font-size: .85rem; margin: 0 0 2rem;
|
|
29
|
+
}
|
|
30
|
+
dl.meta dt { color: var(--muted); font-weight: 600; }
|
|
31
|
+
dl.meta dd { margin: 0; }
|
|
32
|
+
code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: .85em;
|
|
33
|
+
background: var(--code-bg); padding: .1em .35em; border-radius: 4px; }
|
|
34
|
+
pre {
|
|
35
|
+
background: var(--code-bg); border: 1px solid var(--border); border-radius: 8px;
|
|
36
|
+
padding: .9rem 1rem; overflow-x: auto; font-size: .8rem; line-height: 1.5;
|
|
37
|
+
}
|
|
38
|
+
pre code { background: none; padding: 0; }
|
|
39
|
+
.k { color: #bb9af7; } .s { color: #9ece6a; } .c { color: #565f89; font-style: italic; }
|
|
40
|
+
.f { color: #7aa2f7; } .t { color: #e0af68; } .n { color: #f7768e; }
|
|
41
|
+
.note {
|
|
42
|
+
border-left: 3px solid var(--accent); background: var(--panel);
|
|
43
|
+
padding: .6rem .9rem; border-radius: 0 8px 8px 0; margin: 1rem 0; font-size: .9rem;
|
|
44
|
+
}
|
|
45
|
+
.warn { border-left-color: var(--warn); }
|
|
46
|
+
.danger { border-left-color: var(--bad); }
|
|
47
|
+
table.cmp { border-collapse: collapse; width: 100%; font-size: .85rem; margin: 1rem 0; }
|
|
48
|
+
table.cmp th, table.cmp td { border: 1px solid var(--border); padding: .45rem .6rem; text-align: left; vertical-align: top; }
|
|
49
|
+
table.cmp th { background: var(--panel2); }
|
|
50
|
+
figure { margin: 1.5rem 0; }
|
|
51
|
+
figcaption { color: var(--muted); font-size: .8rem; margin-top: .4rem; }
|
|
52
|
+
.svgbox { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: .75rem; }
|
|
53
|
+
footer { color: var(--muted); font-size: .8rem; margin-top: 3.5rem; border-top: 1px solid var(--border); padding-top: 1rem; }
|
|
54
|
+
strong { color: #fff; }
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<main>
|
|
59
|
+
|
|
60
|
+
<h1>Modules, not entities — and tenancy as a structural feature of cvx-kit</h1>
|
|
61
|
+
|
|
62
|
+
<dl class="meta">
|
|
63
|
+
<dt>Date</dt><dd>2026-08-13</dd>
|
|
64
|
+
<dt>Input shape</dt><dd>concept</dd>
|
|
65
|
+
<dt>Subject</dt><dd>Evolving cvx-kit's domain layer from entity-per-folder to module segregation, and designing multi-tenancy into the kit — grounded in ~/dev/one-office (multi-tenant Convex ERP) and ~/dev/cvx</dd>
|
|
66
|
+
</dl>
|
|
67
|
+
|
|
68
|
+
<p><strong>The short version.</strong> Two upgrades, and they're independent:
|
|
69
|
+
(1) the unit of the domain layer should be the <em>module</em> (a bounded
|
|
70
|
+
context owning several related tables), not the entity — your docs currently
|
|
71
|
+
imply one folder per table, and one-office proves the module shape scales
|
|
72
|
+
better; (2) tenancy in one-office is not a convention, it's a
|
|
73
|
+
<strong>five-layer structural machine</strong> — schema injection,
|
|
74
|
+
registry-generated row-level security, branded contexts, post-image triggers,
|
|
75
|
+
and architecture tests — and almost all of it is generic enough to live in
|
|
76
|
+
cvx-kit as opt-in machinery, with the policy (which identity provider, which
|
|
77
|
+
claim is the tenant) staying injected, exactly like <code>createAuthFunctions</code>
|
|
78
|
+
already works.</p>
|
|
79
|
+
|
|
80
|
+
<h2>Part 1 — The domain unit is a module, not a table</h2>
|
|
81
|
+
|
|
82
|
+
<p>Your current docs say <code>domain/<entity>/</code>, one directory per
|
|
83
|
+
entity, one <code>zodTable</code> per directory. That was true of the app the
|
|
84
|
+
kit was extracted from — an <em>administration</em> app with few entities.
|
|
85
|
+
one-office has dozens of tables and does something different: the domain
|
|
86
|
+
layer is split into <strong>bounded contexts</strong>, each owning
|
|
87
|
+
<em>many</em> tables that change together:</p>
|
|
88
|
+
|
|
89
|
+
<figure class="wide">
|
|
90
|
+
<div class="svgbox">
|
|
91
|
+
<svg viewBox="0 0 880 300" xmlns="http://www.w3.org/2000/svg" font-family="ui-monospace, Menlo, monospace" font-size="12">
|
|
92
|
+
<!-- entity-per-folder side -->
|
|
93
|
+
<text x="110" y="22" fill="#9aa3b2" font-size="13" font-weight="bold">entity-per-folder (current docs)</text>
|
|
94
|
+
<g fill="#1d212b" stroke="#2a2f3a">
|
|
95
|
+
<rect x="20" y="40" width="120" height="34" rx="6"/>
|
|
96
|
+
<rect x="20" y="82" width="120" height="34" rx="6"/>
|
|
97
|
+
<rect x="20" y="124" width="120" height="34" rx="6"/>
|
|
98
|
+
<rect x="20" y="166" width="120" height="34" rx="6"/>
|
|
99
|
+
<rect x="20" y="208" width="120" height="34" rx="6"/>
|
|
100
|
+
<rect x="20" y="250" width="120" height="34" rx="6"/>
|
|
101
|
+
</g>
|
|
102
|
+
<g fill="#e6e9ef">
|
|
103
|
+
<text x="32" y="62">products/</text>
|
|
104
|
+
<text x="32" y="104">prices/</text>
|
|
105
|
+
<text x="32" y="146">unitsOfMeasure/</text>
|
|
106
|
+
<text x="32" y="188">warehouses/</text>
|
|
107
|
+
<text x="32" y="230">stockEntries/</text>
|
|
108
|
+
<text x="32" y="272">itemLedger/</text>
|
|
109
|
+
</g>
|
|
110
|
+
<text x="170" y="160" fill="#f7768e" font-size="20">→</text>
|
|
111
|
+
<text x="155" y="185" fill="#f7768e" font-size="11">100 folders,</text>
|
|
112
|
+
<text x="155" y="200" fill="#f7768e" font-size="11">blurred seams</text>
|
|
113
|
+
|
|
114
|
+
<!-- module side -->
|
|
115
|
+
<text x="330" y="22" fill="#9aa3b2" font-size="13" font-weight="bold">module segregation (one-office)</text>
|
|
116
|
+
<g>
|
|
117
|
+
<rect x="260" y="40" width="270" height="110" rx="8" fill="#171a21" stroke="#7aa2f7"/>
|
|
118
|
+
<text x="275" y="62" fill="#7aa2f7" font-weight="bold">domain/catalog/</text>
|
|
119
|
+
<text x="275" y="82" fill="#e6e9ef">tables: products, prices,</text>
|
|
120
|
+
<text x="275" y="98" fill="#e6e9ef">unitsOfMeasure, …</text>
|
|
121
|
+
<text x="275" y="124" fill="#9aa3b2">schema.ts · table.ts · rules.ts</text>
|
|
122
|
+
<text x="275" y="140" fill="#9aa3b2">commands.ts · queries.ts · shared.ts</text>
|
|
123
|
+
</g>
|
|
124
|
+
<g>
|
|
125
|
+
<rect x="260" y="165" width="270" height="110" rx="8" fill="#171a21" stroke="#7aa2f7"/>
|
|
126
|
+
<text x="275" y="187" fill="#7aa2f7" font-weight="bold">domain/inventory/</text>
|
|
127
|
+
<text x="275" y="207" fill="#e6e9ef">tables: warehouses, stockEntries,</text>
|
|
128
|
+
<text x="275" y="223" fill="#e6e9ef">itemLedger, …</text>
|
|
129
|
+
<text x="275" y="249" fill="#9aa3b2">same fixed file roles,</text>
|
|
130
|
+
<text x="275" y="265" fill="#9aa3b2">module owns several tables</text>
|
|
131
|
+
</g>
|
|
132
|
+
<!-- allowed dependency arrows -->
|
|
133
|
+
<path d="M 530 220 C 590 220 590 95 532 95" fill="none" stroke="#9ece6a" stroke-width="1.5" marker-end="url(#ar)"/>
|
|
134
|
+
<text x="560" y="150" fill="#9ece6a" font-size="11" transform="rotate(0)">rules/queries only</text>
|
|
135
|
+
|
|
136
|
+
<!-- application layer -->
|
|
137
|
+
<g>
|
|
138
|
+
<rect x="660" y="80" width="200" height="70" rx="8" fill="#171a21" stroke="#e0af68"/>
|
|
139
|
+
<text x="675" y="103" fill="#e0af68" font-weight="bold">application/</text>
|
|
140
|
+
<text x="675" y="121" fill="#e6e9ef" font-size="11">cross-module atomic</text>
|
|
141
|
+
<text x="675" y="136" fill="#e6e9ef" font-size="11">workflows (writes)</text>
|
|
142
|
+
</g>
|
|
143
|
+
<path d="M 662 115 C 610 115 600 95 532 90" fill="none" stroke="#e0af68" stroke-width="1.5" marker-end="url(#ar2)"/>
|
|
144
|
+
<path d="M 662 130 C 610 140 600 210 532 212" fill="none" stroke="#e0af68" stroke-width="1.5" marker-end="url(#ar2)"/>
|
|
145
|
+
<defs>
|
|
146
|
+
<marker id="ar" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
|
147
|
+
<path d="M0,0 L8,4 L0,8 z" fill="#9ece6a"/></marker>
|
|
148
|
+
<marker id="ar2" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
|
149
|
+
<path d="M0,0 L8,4 L0,8 z" fill="#e0af68"/></marker>
|
|
150
|
+
</defs>
|
|
151
|
+
</svg>
|
|
152
|
+
</div>
|
|
153
|
+
<figcaption>Left: what your docs currently prescribe. Right: one-office's
|
|
154
|
+
shape — the same fixed file roles, but the directory is a bounded context
|
|
155
|
+
owning related tables; cross-module writes go up into an
|
|
156
|
+
<code>application/</code> layer, and sibling reads are restricted to
|
|
157
|
+
<code>rules.ts</code>/<code>queries.ts</code>.</figcaption>
|
|
158
|
+
</figure>
|
|
159
|
+
|
|
160
|
+
<p>The crucial insight from one-office: <strong>nothing about the file
|
|
161
|
+
anatomy changes</strong>. Every module still has exactly
|
|
162
|
+
<code>schema.ts · table.ts · constants.ts · commands.ts · queries.ts ·
|
|
163
|
+
rules.ts · shared.ts · triggers.ts · __tests__/</code> — its CODE_CONDUCT
|
|
164
|
+
spells out the identical roles yours does. What changes is the <em>grain</em>:
|
|
165
|
+
<code>domain/catalog/schema.ts</code> declares several
|
|
166
|
+
<code>zodTable</code>s, and <code>domain/catalog/table.ts</code> exports one
|
|
167
|
+
merged <code>catalogTables</code> map. The module registry in
|
|
168
|
+
<code>domain/table.ts</code> then reads like a table of contents for the
|
|
169
|
+
whole business:</p>
|
|
170
|
+
|
|
171
|
+
<pre><code><span class="c">// one-office convex/domain/table.ts — the whole backend in one expression</span>
|
|
172
|
+
<span class="k">export const</span> domainTables = { ...sharedTables, ...tenancyTables, ...catalogTables,
|
|
173
|
+
...inventoryTables, ...purchasingTables, ...salesTables, ...posTables } <span class="k">as const</span></code></pre>
|
|
174
|
+
|
|
175
|
+
<h3>What defines a module boundary?</h3>
|
|
176
|
+
|
|
177
|
+
<p>Not table count — <em>change cohesion and vocabulary</em>. one-office's
|
|
178
|
+
<code>CONTEXT.md</code> is a ubiquitous-language glossary (each term with
|
|
179
|
+
explicit "Avoid:" antonyms), and the modules fall out of it: things named in
|
|
180
|
+
the same breath (<code>products</code>, <code>prices</code>,
|
|
181
|
+
<code>unitsOfMeasure</code>) live together as <code>catalog/</code>. A good
|
|
182
|
+
test for your docs: <strong>two tables belong in one module when a single
|
|
183
|
+
command routinely writes both, or when one's vocabulary is meaningless
|
|
184
|
+
without the other</strong>. If neither is true, separate modules.</p>
|
|
185
|
+
|
|
186
|
+
<h3>The dependency rules that make it safe</h3>
|
|
187
|
+
|
|
188
|
+
<p>Measured from one-office's actual imports, the discipline is precise —
|
|
189
|
+
and this is what keeps modules from re-blurring into a monolith:</p>
|
|
190
|
+
|
|
191
|
+
<table class="cmp">
|
|
192
|
+
<tr><th>Rule</th><th>Meaning</th></tr>
|
|
193
|
+
<tr><td><strong>Sibling reads: <code>rules.ts</code> / <code>queries.ts</code> only</strong></td>
|
|
194
|
+
<td>inventory may import <code>catalog/rules</code> (pure predicates) and
|
|
195
|
+
<code>catalog/queries</code>. It may <em>never</em> import
|
|
196
|
+
<code>catalog/commands.ts</code> — one module never mutates another's state.</td></tr>
|
|
197
|
+
<tr><td><strong>Cross-module writes: <code>application/</code></strong></td>
|
|
198
|
+
<td>A workflow that must atomically touch sales + inventory + invoicing lives
|
|
199
|
+
in <code>application/postSalesDocument.ts</code>, above both modules. The
|
|
200
|
+
<code>api/</code> adapters then import <code>application/*</code> and their
|
|
201
|
+
own module — never a second module.</td></tr>
|
|
202
|
+
<tr><td><strong><code>domain/shared/</code> stays generic</strong></td>
|
|
203
|
+
<td>Cross-cutting kernels (permissions, idempotency, fixed-point math, the
|
|
204
|
+
command/query policy classes) — never business nouns.</td></tr>
|
|
205
|
+
</table>
|
|
206
|
+
|
|
207
|
+
<div class="note">
|
|
208
|
+
<strong>For cvx-kit this is a docs change, not a code change.</strong> The kit
|
|
209
|
+
has no <code>domain/</code> awareness in code; the entity-per-folder rule
|
|
210
|
+
lives in <code>conventions.md</code>/<code>architecture.md</code>. Rewrite the
|
|
211
|
+
grain: <code>domain/<module>/</code> owning 1..n related tables, the
|
|
212
|
+
sibling-import rule upgraded from "never" to "rules/queries only, never
|
|
213
|
+
commands", and a new optional <code>application/</code> layer for
|
|
214
|
+
cross-module atomic workflows. A single-context app degenerates gracefully:
|
|
215
|
+
one module, or a module per entity — the current shape remains a special
|
|
216
|
+
case of the new rule.</div>
|
|
217
|
+
|
|
218
|
+
<h2>Part 2 — What multi-tenant actually means in one-office</h2>
|
|
219
|
+
|
|
220
|
+
<p>Your origin app was single-org administration: whoever is in the org
|
|
221
|
+
administers the data, end of story. one-office serves many organizations from
|
|
222
|
+
one deployment, so every row must answer "whose row is this?" and every read
|
|
223
|
+
and write must be provably scoped. The design decisions, from its ADR:</p>
|
|
224
|
+
|
|
225
|
+
<ul>
|
|
226
|
+
<li><strong>Tenant = the WorkOS organization id, stored as a plain string
|
|
227
|
+
field named <code>tenant</code> on every business row.</strong> No local
|
|
228
|
+
orgs/users/memberships tables — WorkOS stays the identity authority, Convex
|
|
229
|
+
stores one operational-settings record per org. No shadow auth tables to
|
|
230
|
+
drift.</li>
|
|
231
|
+
<li><strong>The active tenant is always derived server-side from the JWT's
|
|
232
|
+
organization claim</strong> — never accepted as an argument. Org switching is
|
|
233
|
+
a frontend/WorkOS concern; when the user switches, the next request carries a
|
|
234
|
+
different claim.</li>
|
|
235
|
+
<li><strong>Every index on a tenant table is tenant-prefixed</strong>:
|
|
236
|
+
<code>by_tenant</code>, <code>by_tenant_code</code>,
|
|
237
|
+
<code>by_tenant_office_archived_at</code>. Reads are impossible to express
|
|
238
|
+
efficiently <em>without</em> scoping.</li>
|
|
239
|
+
<li><strong>Tables split into two registries</strong>: tenant tables (get all
|
|
240
|
+
the machinery below) and private/global tables (platform state, cross-tenant
|
|
241
|
+
uniqueness claims, reconciliation jobs) — deliberately excluded, and therefore
|
|
242
|
+
the sharpest remaining footgun surface.</li>
|
|
243
|
+
</ul>
|
|
244
|
+
|
|
245
|
+
<h3>Isolation is defense-in-depth, not a convention</h3>
|
|
246
|
+
|
|
247
|
+
<figure class="wide">
|
|
248
|
+
<div class="svgbox">
|
|
249
|
+
<svg viewBox="0 0 880 330" xmlns="http://www.w3.org/2000/svg" font-family="ui-monospace, Menlo, monospace" font-size="12">
|
|
250
|
+
<defs>
|
|
251
|
+
<marker id="fl" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
|
|
252
|
+
<path d="M0,0 L8,4 L0,8 z" fill="#7aa2f7"/></marker>
|
|
253
|
+
</defs>
|
|
254
|
+
<g>
|
|
255
|
+
<rect x="20" y="30" width="160" height="52" rx="8" fill="#171a21" stroke="#7aa2f7"/>
|
|
256
|
+
<text x="35" y="52" fill="#7aa2f7" font-weight="bold">1 · schema</text>
|
|
257
|
+
<text x="35" y="70" fill="#e6e9ef" font-size="11">tenantTable() injects</text>
|
|
258
|
+
</g>
|
|
259
|
+
<text x="35" y="100" fill="#9aa3b2" font-size="11">tenant field; update</text>
|
|
260
|
+
<text x="35" y="114" fill="#9aa3b2" font-size="11">schema strips it</text>
|
|
261
|
+
|
|
262
|
+
<g>
|
|
263
|
+
<rect x="240" y="30" width="170" height="52" rx="8" fill="#171a21" stroke="#7aa2f7"/>
|
|
264
|
+
<text x="255" y="52" fill="#7aa2f7" font-weight="bold">2 · builders</text>
|
|
265
|
+
<text x="255" y="70" fill="#e6e9ef" font-size="11">tenantQuery/Mutation</text>
|
|
266
|
+
</g>
|
|
267
|
+
<text x="255" y="100" fill="#9aa3b2" font-size="11">resolve tenant from JWT,</text>
|
|
268
|
+
<text x="255" y="114" fill="#9aa3b2" font-size="11">wrap db: triggers → RLS</text>
|
|
269
|
+
|
|
270
|
+
<g>
|
|
271
|
+
<rect x="470" y="30" width="170" height="52" rx="8" fill="#171a21" stroke="#7aa2f7"/>
|
|
272
|
+
<text x="485" y="52" fill="#7aa2f7" font-weight="bold">3 · RLS, deny-default</text>
|
|
273
|
+
<text x="485" y="70" fill="#e6e9ef" font-size="11">generated from registry</text>
|
|
274
|
+
</g>
|
|
275
|
+
<text x="485" y="100" fill="#9aa3b2" font-size="11">read/insert/modify ⇒</text>
|
|
276
|
+
<text x="485" y="114" fill="#9aa3b2" font-size="11">doc.tenant === ctx.tenant</text>
|
|
277
|
+
|
|
278
|
+
<g>
|
|
279
|
+
<rect x="700" y="30" width="160" height="52" rx="8" fill="#171a21" stroke="#7aa2f7"/>
|
|
280
|
+
<text x="715" y="52" fill="#7aa2f7" font-weight="bold">4 · triggers</text>
|
|
281
|
+
<text x="715" y="70" fill="#e6e9ef" font-size="11">post-image invariant</text>
|
|
282
|
+
</g>
|
|
283
|
+
<text x="715" y="100" fill="#9aa3b2" font-size="11">tenant can never be</text>
|
|
284
|
+
<text x="715" y="114" fill="#9aa3b2" font-size="11">reassigned on update</text>
|
|
285
|
+
|
|
286
|
+
<path d="M 182 56 L 238 56" stroke="#7aa2f7" stroke-width="1.5" marker-end="url(#fl)"/>
|
|
287
|
+
<path d="M 412 56 L 468 56" stroke="#7aa2f7" stroke-width="1.5" marker-end="url(#fl)"/>
|
|
288
|
+
<path d="M 642 56 L 698 56" stroke="#7aa2f7" stroke-width="1.5" marker-end="url(#fl)"/>
|
|
289
|
+
|
|
290
|
+
<g>
|
|
291
|
+
<rect x="240" y="160" width="400" height="52" rx="8" fill="#171a21" stroke="#9ece6a"/>
|
|
292
|
+
<text x="255" y="182" fill="#9ece6a" font-weight="bold">5 · architecture tests</text>
|
|
293
|
+
<text x="255" y="200" fill="#e6e9ef" font-size="11">raw builders banned outside an explicit allowlist; RLS registry asserted exhaustive</text>
|
|
294
|
+
</g>
|
|
295
|
+
<path d="M 440 158 L 440 120" stroke="#9ece6a" stroke-width="1.5" stroke-dasharray="4 3"/>
|
|
296
|
+
|
|
297
|
+
<g>
|
|
298
|
+
<rect x="20" y="250" width="840" height="60" rx="8" fill="#171a21" stroke="#e0af68"/>
|
|
299
|
+
<text x="35" y="273" fill="#e0af68" font-weight="bold">Sideguards</text>
|
|
300
|
+
<text x="35" y="292" fill="#e6e9ef" font-size="11">branded ctx (unexported unique symbol — a tenant ctx can't be forged) · requireTenantReference for client-supplied ids (404, not 403 — no existence disclosure) · components take tenant as an explicit arg from the host adapter only</text>
|
|
301
|
+
</g>
|
|
302
|
+
</svg>
|
|
303
|
+
</div>
|
|
304
|
+
<figcaption>one-office's isolation stack. Any single layer can be bypassed;
|
|
305
|
+
the composition can't be bypassed <em>accidentally</em>.</figcaption>
|
|
306
|
+
</figure>
|
|
307
|
+
|
|
308
|
+
<p>The three pieces worth reading in full, because they're the ones cvx-kit
|
|
309
|
+
should absorb:</p>
|
|
310
|
+
|
|
311
|
+
<pre><code><span class="c">// (1) one-office domain/shared/schema.ts — tenancy injected at the schema boundary</span>
|
|
312
|
+
<span class="k">export const</span> <span class="f">tenantTable</span> = (tableName, schema, options = {}) => {
|
|
313
|
+
<span class="k">const</span> definition = <span class="f">zodTable</span>(tableName, (id) => ({ ...schema(id),
|
|
314
|
+
tenant: z.<span class="f">string</span>().<span class="f">min</span>(<span class="t">1</span>) }), {...})
|
|
315
|
+
<span class="c">// tenant exists on every row, and can NEVER appear in an update</span>
|
|
316
|
+
<span class="k">const</span> tenantSafeUpdate = definition.updateSchema.<span class="f">omit</span>({ tenant: <span class="t">true</span> })
|
|
317
|
+
<span class="k">return</span> { ...definition, updateSchema: tenantSafeUpdate }
|
|
318
|
+
}</code></pre>
|
|
319
|
+
|
|
320
|
+
<pre><code><span class="c">// (2) one-office convex/utils.ts — RLS generated mechanically from the registry.</span>
|
|
321
|
+
<span class="c">// Nothing per-table to remember: a new tenantTable is isolated the moment</span>
|
|
322
|
+
<span class="c">// it's registered, or the exhaustiveness test fails.</span>
|
|
323
|
+
<span class="k">export const</span> <span class="f">tenantRules</span> = (tenant: <span class="t">string</span>) =>
|
|
324
|
+
Object.<span class="f">fromEntries</span>(TENANT_TABLE_NAMES.<span class="f">map</span>((tableName) => [tableName, {
|
|
325
|
+
read: <span class="k">async</span> (_ctx, doc) => doc.tenant === tenant,
|
|
326
|
+
insert: <span class="k">async</span> (_ctx, doc) => doc.tenant === tenant,
|
|
327
|
+
modify: <span class="k">async</span> (_ctx, doc) => doc.tenant === tenant,
|
|
328
|
+
}]))
|
|
329
|
+
<span class="k">export const</span> TENANT_RLS_CONFIG = Object.<span class="f">freeze</span>({ defaultPolicy: <span class="s">'deny'</span> })
|
|
330
|
+
|
|
331
|
+
<span class="c">// inside tenantMutation: triggers first, then RLS — order matters</span>
|
|
332
|
+
<span class="k">const</span> { db: triggeredDb } = triggers.<span class="f">wrapDB</span>(ctx)
|
|
333
|
+
db: <span class="f">wrapDatabaseWriter</span>({}, triggeredDb, <span class="f">tenantRules</span>(tenant), TENANT_RLS_CONFIG)</code></pre>
|
|
334
|
+
|
|
335
|
+
<pre><code><span class="c">// (3) one-office convex/triggers.ts — RLS authorizes against the PRE-image,</span>
|
|
336
|
+
<span class="c">// so ownership must also hold on the POST-image. Registered for every tenant table.</span>
|
|
337
|
+
<span class="k">export const</span> <span class="f">rejectTenantReassignment</span> = <span class="k">async</span> (_ctx, change) => {
|
|
338
|
+
<span class="k">if</span> (change.operation === <span class="s">'insert'</span> && !change.newDoc.tenant) <span class="k">throw</span> CROSS_TENANT_REFERENCE
|
|
339
|
+
<span class="k">if</span> (change.operation === <span class="s">'update'</span> && change.oldDoc.tenant !== change.newDoc.tenant)
|
|
340
|
+
<span class="k">throw</span> CROSS_TENANT_REFERENCE <span class="c">// 'Tenant ownership cannot be reassigned'</span>
|
|
341
|
+
}</code></pre>
|
|
342
|
+
|
|
343
|
+
<div class="note warn"><strong>Why layer 4 exists at all</strong> is the best
|
|
344
|
+
lesson in the codebase: RLS <code>modify</code> checks the document you're
|
|
345
|
+
<em>changing</em> (pre-image). Without the trigger, a mutation could legally
|
|
346
|
+
load its own tenant's row and patch <code>tenant</code> to another org's id —
|
|
347
|
+
a write RLS happily allows. Every layer covers a hole in the previous one.
|
|
348
|
+
That's the mindset tenancy requires.</div>
|
|
349
|
+
|
|
350
|
+
<h2>Part 3 — How this lands in cvx-kit</h2>
|
|
351
|
+
|
|
352
|
+
<p>The dividing line — and it's the same line the kit already draws for auth —
|
|
353
|
+
is <strong>mechanism in the kit, policy in the host</strong>. WorkOS, the JWT
|
|
354
|
+
claim shape, permission slugs: policy, injected. Field injection, RLS
|
|
355
|
+
generation, trigger invariants, builder wiring: mechanism, and today every
|
|
356
|
+
consumer would have to hand-roll all of it. Proposal, in kit-API terms:</p>
|
|
357
|
+
|
|
358
|
+
<h3>3a. <code>tenantTable</code> in <code>cvx-kit/zod-table</code></h3>
|
|
359
|
+
|
|
360
|
+
<pre><code><span class="k">export const</span> <entities> = <span class="f">tenantTable</span>(<span class="s">'<entities>'</span>, (id) => ({ ... }), {
|
|
361
|
+
commandFields: [...], publicFields: [...],
|
|
362
|
+
})
|
|
363
|
+
<span class="c">// = zodTable + { tenant: z.string().min(1) } baked in, exactly like timestamps:</span>
|
|
364
|
+
<span class="c">// • excluded from insertSchema/commandInput (server-owned, like createdAt)</span>
|
|
365
|
+
<span class="c">// • stripped from updateSchema — tenant is immutable at the type boundary</span>
|
|
366
|
+
<span class="c">// • never in publicDto unless explicitly allowlisted</span></code></pre>
|
|
367
|
+
|
|
368
|
+
<p>This mirrors the kit's existing timestamp opinion precisely: a
|
|
369
|
+
server-owned field, injected once, unwritable from any client-facing mask.</p>
|
|
370
|
+
|
|
371
|
+
<h3>3b. A tenancy module: registry → rules + trigger</h3>
|
|
372
|
+
|
|
373
|
+
<pre><code><span class="c">// cvx-kit/tenancy (new export)</span>
|
|
374
|
+
<span class="k">export function</span> <span class="f">createTenancy</span><DataModel>(config: {
|
|
375
|
+
tables: <span class="k">readonly</span> TableNamesInDataModel<DataModel>[] <span class="c">// THE registry</span>
|
|
376
|
+
}) {
|
|
377
|
+
<span class="k">return</span> {
|
|
378
|
+
tables, <span class="c">// re-exported for tests</span>
|
|
379
|
+
<span class="f">rules</span>: (tenant) => ..., <span class="c">// deny-default RLS map, generated</span>
|
|
380
|
+
<span class="f">registerGuards</span>: (triggers) => ..., <span class="c">// rejectTenantReassignment on every table</span>
|
|
381
|
+
<span class="f">requireTenantReference</span>: ..., <span class="c">// 404-not-403 cross-reference guard</span>
|
|
382
|
+
}
|
|
383
|
+
}</code></pre>
|
|
384
|
+
|
|
385
|
+
<p>One registry drives everything — the property that made one-office safe is
|
|
386
|
+
that <em>adding a table to the registry is the entire onboarding</em>; there
|
|
387
|
+
is no second place to forget.</p>
|
|
388
|
+
|
|
389
|
+
<h3>3c. Tenant-aware constructors via <code>createAuthFunctions</code></h3>
|
|
390
|
+
|
|
391
|
+
<pre><code><span class="f">createAuthFunctions</span><DataModel>({
|
|
392
|
+
...,
|
|
393
|
+
tenancy: { <span class="c">// optional — absent = today's behavior</span>
|
|
394
|
+
tenancy, <span class="c">// the createTenancy(...) instance</span>
|
|
395
|
+
resolve: (bundle) => bundle.org.organizationId, <span class="c">// policy: claim → tenant, injected</span>
|
|
396
|
+
},
|
|
397
|
+
})
|
|
398
|
+
<span class="c">// adds: tenantQuery, tenantMutation, tenantAction, tenantSystemMutation</span>
|
|
399
|
+
<span class="c">// ctx gains ctx.tenant; mutation db is wrapped triggers → RLS(deny);</span>
|
|
400
|
+
<span class="c">// insert paths stamp tenant from ctx — handlers never write it</span></code></pre>
|
|
401
|
+
|
|
402
|
+
<p>The auth bundle already carries <code>org.organizationId</code> — the kit
|
|
403
|
+
already resolves the org from the JWT and refuses requests without one. The
|
|
404
|
+
tenant is one <code>resolve</code> away. Keeping <code>tenancy</code> optional
|
|
405
|
+
means the single-org administration apps (your origin app) change nothing,
|
|
406
|
+
and a multi-tenant app opts in with ~10 lines.</p>
|
|
407
|
+
|
|
408
|
+
<h3>3d. Alignment that already exists</h3>
|
|
409
|
+
|
|
410
|
+
<p>Two kit pieces anticipated this design and need no change, only
|
|
411
|
+
documentation:</p>
|
|
412
|
+
|
|
413
|
+
<ul>
|
|
414
|
+
<li><strong>Approvals' <code>scopeRef</code> is the tenant slot.</strong> The
|
|
415
|
+
component was already built tenancy-agnostic — pass
|
|
416
|
+
<code>ctx.tenant</code> as <code>scopeRef</code> and per-tenant approval
|
|
417
|
+
isolation falls out.</li>
|
|
418
|
+
<li><strong>Components take <code>tenant</code> as an explicit argument.</strong>
|
|
419
|
+
one-office's rule — the host adapter is the only code allowed to supply it,
|
|
420
|
+
always from <code>ctx.tenant</code>, never from client input — is exactly the
|
|
421
|
+
kit's existing "opaque validated data across the component boundary" rule.</li>
|
|
422
|
+
</ul>
|
|
423
|
+
|
|
424
|
+
<h3>3e. What stays out of the kit</h3>
|
|
425
|
+
|
|
426
|
+
<table class="cmp">
|
|
427
|
+
<tr><th>Concern</th><th>Why it stays host-side</th></tr>
|
|
428
|
+
<tr><td>Org switching, invitations, memberships</td><td>Identity-provider surface (WorkOS UI + webhooks). The kit sees only "the claim changed".</td></tr>
|
|
429
|
+
<tr><td>Permission catalogs (<code>erp.sales.manage</code>…)</td><td>Product vocabulary. The kit's role constructors are the extension point; a permission-claim check plugs into <code>mapRole</code>/role gates.</td></tr>
|
|
430
|
+
<tr><td>Tenant provisioning/archival</td><td>A webhook-driven domain command in the host (one-office: idempotent <code>provisionOrganization</code>).</td></tr>
|
|
431
|
+
<tr><td>Global/private tables</td><td>Deliberately outside the machinery. The kit's job is to make the registry split <em>visible</em> (docs + a conventions rule), not to guard what it can't see.</td></tr>
|
|
432
|
+
</table>
|
|
433
|
+
|
|
434
|
+
<div class="note danger"><strong>The honest footguns to document with the
|
|
435
|
+
feature</strong> — all learned the hard way in one-office: (1) actions have no
|
|
436
|
+
db, so action→internal handoffs must pass the server-derived tenant, never a
|
|
437
|
+
caller value — the internal builders should be asserted to <em>not accept</em>
|
|
438
|
+
a tenant arg; (2) provider ids (memberships, invitations) are global — org-scoped
|
|
439
|
+
mutations must re-check ownership against the active org or you ship a
|
|
440
|
+
cross-tenant IDOR; (3) private/global tables get none of the machinery — every
|
|
441
|
+
one is a per-handler-discipline zone and should be rare and named; (4)
|
|
442
|
+
cross-reference loads must return "not found", never "forbidden" — existence
|
|
443
|
+
is also tenant data.</div>
|
|
444
|
+
|
|
445
|
+
<h3>Suggested order of work</h3>
|
|
446
|
+
|
|
447
|
+
<ol>
|
|
448
|
+
<li><strong>Docs first</strong> (no code risk): rewrite
|
|
449
|
+
<code>conventions.md</code>/<code>architecture.md</code> to module grain +
|
|
450
|
+
sibling rules/queries rule + optional <code>application/</code> layer.</li>
|
|
451
|
+
<li><code>tenantTable</code> in zod-table (small, additive, mirrors timestamps).</li>
|
|
452
|
+
<li><code>cvx-kit/tenancy</code>: registry → rules/guards/reference-check.</li>
|
|
453
|
+
<li><code>tenancy</code> option in <code>createAuthFunctions</code> + the
|
|
454
|
+
<code>tenant*</code> constructors (the only intrusive piece — wrap order
|
|
455
|
+
triggers→RLS, ctx branding, insert stamping).</li>
|
|
456
|
+
<li>Architecture-test recipes in the docs: registry exhaustiveness,
|
|
457
|
+
raw-builder ban already exists — add "tenant tables use tenantTable" and
|
|
458
|
+
"internal builders accept no tenant arg".</li>
|
|
459
|
+
</ol>
|
|
460
|
+
|
|
461
|
+
<p>Steps 1–3 are safe to ship in a patch release; step 4 deserves its own
|
|
462
|
+
minor version and a design pass on the ctx type (one-office's unexported
|
|
463
|
+
<code>unique symbol</code> branding is worth copying so a "tenant ctx" can't
|
|
464
|
+
be hand-assembled).</p>
|
|
465
|
+
|
|
466
|
+
<footer>Composed 2026-08-13 by ce-explain</footer>
|
|
467
|
+
</main>
|
|
468
|
+
</body>
|
|
469
|
+
</html>
|
package/docs/llms.md
CHANGED
|
@@ -36,7 +36,11 @@ function constructors, a trigger registry, an audited command protocol
|
|
|
36
36
|
5. **State changes are commands**: an operations registry with mandatory
|
|
37
37
|
`classification` and `audit()` per operation, executed via
|
|
38
38
|
`commands.exec({ operation, handler })`. Audit writes happen in the same
|
|
39
|
-
transaction.
|
|
39
|
+
transaction. Optional per-operation `permission` (checked via the
|
|
40
|
+
Foundation's injected `checkPermission`) and `guard(ctx, command)`
|
|
41
|
+
preconditions run BEFORE the handler, plus a registry-wide default guard
|
|
42
|
+
(`new Command(operations, { guard })`). Order: permission → default guard
|
|
43
|
+
→ operation guard → handler → audit.
|
|
40
44
|
6. **Never write timestamps by hand.** `createdAt`/`updatedAt` are maintained
|
|
41
45
|
by the `timestamps` trigger; `archivedAt` is the app-controlled soft-delete
|
|
42
46
|
marker.
|
|
@@ -47,15 +51,23 @@ function constructors, a trigger registry, an audited command protocol
|
|
|
47
51
|
tables, no reaching private children (`components.approvals.workflow`).
|
|
48
52
|
9. **Operation names are `domain.verb` lowercase-dotted; error codes are
|
|
49
53
|
UPPER_SNAKE** — otherwise observability silently drops the events.
|
|
54
|
+
10. **Row-level security is configured, not hand-rolled**: the optional
|
|
55
|
+
`security` config on `createAuthFunctions` takes role-level `rules`
|
|
56
|
+
(works standalone) and/or `tenancy` (a table registry; adds
|
|
57
|
+
`ctx.tenant`, deny-default isolation, and pairs with `tenantTable` +
|
|
58
|
+
`tenantOwnership`). Multi-tenant apps stamp inserts with
|
|
59
|
+
`tenant: ctx.tenant` and re-verify client ids with
|
|
60
|
+
`requireTenantReference`. See `tenancy.md`.
|
|
50
61
|
|
|
51
62
|
## Exports map
|
|
52
63
|
|
|
53
64
|
| Import | Provides |
|
|
54
65
|
|---|---|
|
|
55
66
|
| `cvx-kit` | everything below re-exported (except components' defaults) |
|
|
56
|
-
| `cvx-kit/zod-table` | `zodTable`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
|
|
57
|
-
| `cvx-kit/auth` | `createAuthFunctions
|
|
58
|
-
| `cvx-kit/
|
|
67
|
+
| `cvx-kit/zod-table` | `zodTable`, `tenantTable`, `createModule`, `zodVariantTable`, `jsonSafeZid`, `TIMESTAMP_FIELDS` |
|
|
68
|
+
| `cvx-kit/auth` | `createAuthFunctions` (incl. optional `security` RLS config), `createInclude`, `defaultRoleMap` |
|
|
69
|
+
| `cvx-kit/tenancy` | `createTenantRules`, `composeRules`, `requireTenantReference`, `assertTenantOwned`, `TENANT_FIELD` |
|
|
70
|
+
| `cvx-kit/triggers` | `createTriggers`, `timestamps`, `appendOnly`, `noDelete`, `tenantOwnership`, `Triggers` |
|
|
59
71
|
| `cvx-kit/errors` | `KitError`, `defaultErrors`, `ErrorFactory` |
|
|
60
72
|
| `cvx-kit/components/foundation` | `Foundation`, `executeResultBoundary`, `projectResult`, `Observability`; default export = component config for `app.use` |
|
|
61
73
|
| `cvx-kit/components/approvals` | `Approvals` client; default export = component config for `app.use` |
|