Skip to main content

apiplant_core/
defaults.rs

1//! Built-in resources.
2//!
3//! `organization`, `membership`, `user`, `api_key` and `oauth_connection` exist
4//! in every app. Together they make apps multitenant out of the box: users join
5//! organisations through memberships (which also carry their **role within that
6//! organisation**), and every other resource is isolated per organisation by
7//! default.
8//!
9//! `invitation` and `auth_token` are the two tables behind the flows that reach
10//! somebody through their mailbox — being invited to an organisation,
11//! confirming an address, resetting a password. They exist in every app, and in
12//! one with no `[email]` provider they simply stay empty: the endpoints that
13//! write to them are not mounted at all.
14//!
15//! Two sets are *conditional*, and for the same reason: they are machinery for
16//! a feature most apps do not turn on, and a table nobody ever writes to is
17//! noise in a dashboard. The `billing_*` resources arrive with a `[payments]`
18//! provider ([`billing_builtins`]), and `oauth_state` with an `[oauth]` one
19//! ([`oauth_builtins`]).
20//!
21//! Drop a `resources/<name>.toml` with the same `name` to replace a built-in and
22//! add fields or tweak permissions while keeping the machinery working.
23
24use crate::schema::Resource;
25
26/// The `organization` — the tenant. `global` because its rows *are* the
27/// organisations; membership (not an `organization_id`) decides who sees them.
28pub const ORGANIZATION_TOML: &str = r#"
29[resource]
30name = "organization"
31scope = "global"
32timestamps = true
33
34[permissions]
35list   = "member"          # organisations you belong to
36read   = "member"
37create = "authenticated"   # anyone may start one (and becomes its admin)
38update = "role:admin"      # an admin *of that organisation*
39delete = "role:admin"
40
41[fields.name]
42type = "string"
43required = true
44
45[fields.slug]
46type = "string"
47unique = true
48
49# What *kind* of organisation this is — `school`, `staff`, `customer`. A
50# permission can be narrowed to one (`role:admin@org_class=school`), so this
51# column decides who gets in, and it is therefore server-owned: it is stripped
52# from every client body like `organization_id` is, and only somebody the
53# `[organization] org_class_editors` setting names may set it. Unset by
54# default, which no class-qualified policy matches.
55[fields.org_class]
56type = "string"
57max_length = 64
58
59# A logo, as a URL a browser can fetch. Nothing sets it — an organisation is not
60# handed to us by an identity provider the way a person is — so it is here for
61# an app to fill and for every interface to read: the dashboard's workspace
62# switcher shows it in place of the initials it would otherwise draw.
63#
64# A `file` field, so the dashboard offers an upload into `[storage]` as well as
65# a URL box. What is stored is a string either way — an uploaded logo is a
66# relative link this server answers, a pasted one is whatever was pasted.
67[fields.avatar_url]
68type = "file"
69max_length = 1024
70"#;
71
72/// A user's membership in an organisation, carrying their role there. The
73/// join table behind the N:N between `user` and `organization`.
74pub const MEMBERSHIP_TOML: &str = r#"
75[resource]
76name = "membership"
77scope = "organization"
78timestamps = true
79
80[permissions]
81list   = "member"          # members can see who else is in the org
82read   = "member"
83create = "role:admin"      # admins add members
84update = "role:admin"
85delete = "role:admin"
86
87# Built-in function: lets `create` name the person by `email` instead of by
88# `user_id`, and refuses a duplicate membership. The lookup has to happen here
89# because `user` is only readable by people you already share an org with.
90[hooks]
91before_create = "apiplant_organization_join"
92
93[fields.user_id]
94type = "reference"
95references = "user"
96required = true
97on_delete = "cascade"      # a deleted account takes its memberships with it
98
99[fields.organization_id]
100type = "reference"
101references = "organization"
102required = true
103
104[fields.role]
105type = "string"            # the member's *primary* role here, e.g. "admin".
106                           # Further roles are `membership_role` rows; a
107                           # `role:` permission is checked against all of them.
108"#;
109
110/// A single role held by a membership.
111///
112/// Roles are a set, not a slot: someone can be a `billing` *and* a `support`
113/// person without either displacing the other, which one column cannot express.
114/// [`MEMBERSHIP_TOML`]'s `role` stays as the member's **primary** role — it is
115/// what existing apps and hook contexts read — and these rows are the rest.
116/// Together they are the roles a `role:` permission is checked against.
117///
118/// `admin` is special: it satisfies every role check in its organisation
119/// without needing a row per role, so granting someone `admin` grants them
120/// everything the app defines.
121pub const MEMBERSHIP_ROLE_TOML: &str = r#"
122[resource]
123name = "membership_role"
124scope = "organization"
125timestamps = true
126
127[permissions]
128list   = "member"          # members can see who holds what
129read   = "member"
130create = "role:admin"      # admins grant roles
131update = "role:admin"
132delete = "role:admin"
133
134[fields.membership_id]
135type = "reference"
136references = "membership"
137required = true
138on_delete = "cascade"      # a removed member takes their roles with them
139
140[fields.organization_id]
141type = "reference"
142references = "organization"
143required = true
144
145[fields.role]
146type = "string"
147required = true
148"#;
149
150/// The default `user`: global (users are shared across organisations) with
151/// email + password auth. Extend via `resources/users.toml`.
152pub const USER_TOML: &str = r#"
153[resource]
154name = "user"
155scope = "global"
156timestamps = true
157
158[permissions]
159list   = "member"          # people you share an organisation with
160read   = "member"
161create = "public"          # registration
162update = "owner"
163delete = "private"
164
165[auth]
166identity_field = "email"
167password_field = "password_hash"
168oauth_providers = []
169
170[fields.email]
171type = "string"
172required = true
173unique = true
174max_length = 320
175
176[fields.password_hash]
177type = "string"
178hidden = true
179
180# When the address was confirmed. Null means unconfirmed — which only *stops*
181# anyone when `[auth] require_email_verification` is on, so an app with no
182# mailer carries the column and never looks at it.
183[fields.email_verified_at]
184type = "timestamp"
185
186[fields.email_verified_at.admin]
187visible = false
188
189# What to call somebody, and what to show beside their name.
190#
191# Both are ordinary nullable columns nothing requires — an app that has no use
192# for either can leave them empty or drop them by replacing this resource. They are
193# here because they are what almost every app wants and what every identity
194# provider hands over: a sign-in through [`[oauth]`](crate::config::OAuthConfig)
195# fills them in, so an account that arrives that way arrives with a name and a
196# picture rather than an email address and a blank.
197#
198# The picture is a `file` field: a provider fills it with the URL it gave us,
199# and somebody changing it in the dashboard uploads one into `[storage]` or
200# types a URL of their own. Both are the same string in the same column.
201[fields.display_name]
202type = "string"
203
204[fields.avatar_url]
205type = "file"
206max_length = 1024
207
208# True when the address above is one apiplant invented rather than one somebody
209# gave it.
210#
211# A sign-in through a provider that releases no address — X, today — still has
212# to put *something* in the identity column, which is required and unique. It
213# writes `<provider>_<id>@oauth.invalid`, at a TLD RFC 2606 reserves so that it
214# can never resolve.
215#
216# This flag is here, in every app, because the framework is the one inventing
217# that value: fabricating an address and not recording that it was fabricated
218# leaves an app to find out by watching mail bounce. With it, "we have an
219# address for this person" stops being the same question as "there is a string
220# in the email column" — which is the question a welcome email, a newsletter
221# and a CSV export all actually mean to ask.
222[fields.email_placeholder]
223type = "boolean"
224default = false
225
226[fields.email_placeholder.admin]
227visible = false
228"#;
229
230/// An invitation to join an organisation, addressed to someone who may not have
231/// an account yet.
232///
233/// This is the table behind `POST <base>/auth/invitations`. The emailed link
234/// carries a token whose **hash** is what lives here, for the same reason an
235/// API key's does: a leaked database should not be a pile of working links.
236///
237/// It is org-scoped, so an admin only ever sees the invitations to their own
238/// organisation, and `read`/`create` are `role:admin` — the endpoints that a
239/// person *without* an account uses (previewing a link, accepting it) are not
240/// CRUD on this resource at all, and reach the row through the token they were
241/// sent rather than through the API's permissions.
242pub const INVITATION_TOML: &str = r#"
243[resource]
244name = "invitation"
245scope = "organization"
246timestamps = true
247
248[permissions]
249list   = "role:admin"      # admins see who is still pending
250read   = "role:admin"
251create = "private"         # issued by POST /auth/invitations, never by hand:
252                           # a row written directly has no token to send
253update = "private"
254delete = "role:admin"      # revoking is deleting the row
255
256[fields.email]
257type = "string"
258required = true
259max_length = 320
260
261[fields.role]
262type = "string"            # the role they will hold once they accept
263
264[fields.token_hash]
265type = "string"
266required = true
267unique = true
268hidden = true
269
270[fields.invited_by]
271type = "reference"
272references = "user"
273on_delete = "set_null"
274
275[fields.expires_at]
276type = "timestamp"
277required = true
278
279# Set when the invitation is used. A row with this filled in is history, kept
280# so "who let them in, and when" survives the membership being edited later.
281[fields.accepted_at]
282type = "timestamp"
283"#;
284
285/// A single-use token sent to an address to prove someone reads it.
286///
287/// Both address confirmation and password reset are the same shape — mint a
288/// secret, mail it, accept it once, before it expires — so they are one table
289/// distinguished by `kind` rather than two that would drift apart.
290///
291/// Entirely `private`: every row is created and spent by the framework's own
292/// endpoints, and there is nothing here anybody should read over the API. The
293/// plaintext exists only in the message that was sent.
294pub const AUTH_TOKEN_TOML: &str = r#"
295[resource]
296name = "auth_token"
297scope = "global"
298timestamps = true
299
300[admin]
301label = "Auth token"
302plural = "Auth tokens"
303visible = false
304
305[permissions]
306list   = "private"
307read   = "private"
308create = "private"
309update = "private"
310delete = "private"
311
312[fields.user_id]
313type = "reference"
314references = "user"
315required = true
316on_delete = "cascade"      # a deleted account takes its live links with it
317
318[fields.kind]
319type = "string"
320required = true            # "email_verification" or "password_reset"
321
322[fields.token_hash]
323type = "string"
324required = true
325unique = true
326hidden = true
327
328[fields.expires_at]
329type = "timestamp"
330required = true
331
332# Set the moment the token is spent, so a link in a mailbox works exactly once.
333[fields.used_at]
334type = "timestamp"
335"#;
336
337/// Default `api_key` resource (global). A valid key authenticates as its owner.
338pub const API_KEY_TOML: &str = r#"
339[resource]
340name = "api_key"
341scope = "global"
342timestamps = true
343
344# "Api key" is what titleising the name produces, and it is not how anybody
345# writes it.
346[admin]
347label = "API key"
348plural = "API keys"
349
350[permissions]
351list   = "owner"
352read   = "owner"
353create = "authenticated"
354update = "private"
355delete = "owner"
356
357[fields.name]
358type = "string"
359
360[fields.token_hash]
361type = "string"
362required = true
363unique = true
364hidden = true
365
366[fields.owner_id]
367type = "reference"
368references = "user"
369required = true
370"#;
371
372/// Default `oauth_connection` resource (global) linking a user to a provider.
373///
374/// One row per (provider, account at that provider). Somebody may hold four of
375/// them, and signing in through any one reaches the same `user` — which is the
376/// whole point of the table: an account is not its GitHub account, it *has* one.
377///
378/// It is a live, ordinary resource: `GET <base>/oauth_connection` is how a
379/// client draws somebody's linked accounts, and it is `owner`-scoped, so that
380/// question needs no filter and cannot answer anybody else's.
381///
382/// The profile columns are refreshed on every sign-in, because people change
383/// their name and their picture. There is deliberately no access token and no
384/// refresh token here: `<base>/auth/oauth` authenticates people and never acts
385/// on their behalf afterwards, so the token is used once — to read the profile —
386/// and dropped. An app that does need to keep calling the provider adds those
387/// columns itself and encrypts them at rest; `hidden` keeps a value out of API
388/// responses, which is not the same as keeping it out of a database dump.
389pub const OAUTH_TOML: &str = r#"
390[resource]
391name = "oauth_connection"
392scope = "global"
393timestamps = true
394
395# Readable by whoever it belongs to — `GET <base>/oauth_connection` is "my
396# linked accounts", with no filter in the request saying so — and written by
397# nobody but the framework.
398#
399# `delete` is **private**, which is the one that looks wrong and is not:
400# removing a connection has an invariant that a row deletion cannot see. An
401# account with no password and no second provider becomes permanently
402# unreachable the moment its last connection goes, so unlinking is
403# `DELETE <base>/auth/oauth/{provider}`, which checks what else is left and
404# refuses the last one. Leaving `delete = "owner"` here would put a door beside
405# that check with nothing behind it but the same table.
406[permissions]
407list   = "owner"
408read   = "owner"
409create = "private"
410update = "private"
411delete = "private"
412
413[fields.provider]
414type = "string"
415required = true
416
417# The provider's own immutable id for this person — GitHub's numeric id,
418# Google's `sub`, X's user id. Never a username: GitHub and X both let people
419# change theirs and let the freed name be taken by somebody else, so an account
420# keyed on one would hand the new owner the old owner's account.
421[fields.provider_user_id]
422type = "string"
423required = true
424
425# `provider:provider_user_id`, so the pair can carry a UNIQUE constraint — a
426# single column is what `unique` can express. It is what makes two simultaneous
427# first-time sign-ins from one GitHub account produce one user instead of two:
428# the loser's insert conflicts, and it reads back the winner's row rather than
429# creating a second account.
430[fields.provider_key]
431type = "string"
432unique = true
433max_length = 320
434
435[fields.owner_id]
436type = "reference"
437references = "user"
438required = true
439on_delete = "cascade"
440
441# What the provider last said about them. `email_verified` is the field the
442# account-matching rule hangs on, so it records what the *provider* claimed
443# rather than what this app would like to be true.
444[fields.email]
445type = "string"
446max_length = 320
447
448[fields.email_verified]
449type = "boolean"
450default = false
451
452[fields.display_name]
453type = "string"
454
455# A plain string, not a `file`: this row is a record of what the provider
456# claimed, so nothing should offer to replace it with an upload.
457[fields.avatar_url]
458type = "string"
459max_length = 1024
460
461[fields.last_login_at]
462type = "timestamp"
463"#;
464
465/// The half-finished handshake: `oauth_state`.
466///
467/// Present only in an app with an `[oauth]` provider, because it is machinery
468/// and not domain: two requests, minutes apart, with a consent screen between
469/// them, and everything the second one must not take on trust from the browser
470/// waiting somewhere the browser cannot reach.
471///
472/// A cache with a TTL would do the same job in less space. A table is used
473/// because it survives a restart — a redeploy in the ninety seconds somebody
474/// spends reading a consent screen should not fail their sign-in — and because
475/// it needs no Redis to exist.
476pub const OAUTH_STATE_TOML: &str = r#"
477[resource]
478name = "oauth_state"
479scope = "global"
480timestamps = true
481
482# Machinery, like `auth_token`: rows appear for ninety seconds while somebody
483# reads a consent screen and are never worth looking at afterwards, so the
484# dashboard does not offer a screen for them.
485[admin]
486label = "OAuth sign-in"
487plural = "OAuth sign-ins"
488visible = false
489
490# Nothing may reach this table over the API — not even the person whose sign-in
491# it is. Every column on it is either a secret or a decision that stops being
492# safe the moment a client can change it, and its only reader is the callback
493# endpoint, which goes to the table directly.
494[permissions]
495list   = "private"
496read   = "private"
497create = "private"
498update = "private"
499delete = "private"
500
501[fields.provider]
502type = "string"
503required = true
504
505# SHA-256 of the `state` parameter, not the parameter. The value itself travels
506# in a URL, through browser history and the provider's logs; only its hash is
507# kept, so this table leaking does not let anybody finish a flow in progress.
508# SHA-256 rather than argon2 for the same reason an API key's hash is: the
509# callback looks the row up *by* this column.
510[fields.state_hash]
511type = "string"
512required = true
513unique = true
514hidden = true
515
516# The PKCE verifier, where the provider supports PKCE. Only its SHA-256 travels
517# with the authorize redirect, so intercepting that redirect — or the code that
518# comes back on it — is not enough to redeem anything.
519[fields.verifier]
520type = "string"
521hidden = true
522
523# Repeated verbatim in the token request, because the provider compares the two
524# and refuses on any difference. Recorded rather than recomputed so that a
525# config change mid-flow cannot strand a sign-in.
526[fields.redirect_uri]
527type = "string"
528max_length = 1024
529
530# Set when the flow was started by somebody already signed in: this is "connect
531# my GitHub", not "sign me in". It is decided here, while holding that account's
532# session, and never read from the callback — which is the difference between
533# linking an account you can prove is yours and linking any account whose id you
534# can name.
535[fields.link_user_id]
536type = "reference"
537references = "user"
538on_delete = "cascade"
539
540# Where to send the browser afterwards. Only ever a path on this site; see the
541# `return_to` handling in the oauth routes.
542[fields.return_to]
543type = "string"
544max_length = 1024
545
546# How the caller asked to be given the token, overriding `[oauth]
547# token_delivery` for this one flow. Empty means "however the app is
548# configured".
549#
550# It is recorded rather than read from the callback for the ordinary reason:
551# the callback is a request the provider makes, and nothing about it is the
552# app's word. A first-party client — the admin dashboard, say — knows how it
553# wants to receive a token, and this is where it says so.
554[fields.token_delivery]
555type = "string"
556max_length = 16
557
558[fields.expires_at]
559type = "timestamp"
560required = true
561
562# Stamped by the callback. A code may be redeemed once, and so may the state
563# that authorised it: a second callback carrying the same one is a double-click
564# or an attack, and is refused either way.
565[fields.used_at]
566type = "timestamp"
567"#;
568
569// --- billing ------------------------------------------------------------
570//
571// The five `billing_*` resources exist only in an app whose `[payments]`
572// section names a provider — see [`billing_builtins`]. They are prefixed for
573// the same reason the built-in functions are: `product` and `price` are words
574// an ordinary app wants for its own domain, and a framework that took them
575// would be taking them from a shop that sells things.
576//
577// The split between them is the split Stripe already makes, and copying it is
578// deliberate: a **product** is the thing you sell, a **price** is one way to
579// pay for it (monthly, yearly, one-off), and a product with three prices is
580// three ways to buy one thing rather than three things. Because these are
581// ordinary resources, the catalogue is CRUD — a `role:admin` can add a plan
582// from the dashboard, and the hooks below mirror it into Stripe — while
583// `billing_customer`, `billing_subscription` and `billing_payment` are
584// `private`: Stripe decides what is paid for and the webhook writes it down.
585
586/// A thing the app sells. Global: the catalogue is the same for every tenant.
587///
588/// Writable by an admin, and every write is mirrored into Stripe by the
589/// [`apiplant_stripe_product`] built-in, so a plan added in the dashboard
590/// exists in Stripe before the row is committed. `stripe_product_id` is what
591/// that hook fills in; nothing else should.
592///
593/// [`apiplant_stripe_product`]: https://docs.rs/apiplant-server
594pub const BILLING_PRODUCT_TOML: &str = r#"
595[resource]
596name = "billing_product"
597scope = "global"
598timestamps = true
599
600[admin]
601label = "Product"
602plural = "Products"
603
604[permissions]
605list   = "public"          # a pricing page is read by people with no account
606read   = "public"
607create = "role:admin"
608update = "role:admin"
609delete = "role:admin"
610
611# Mirror the catalogue into Stripe. Creating the row creates the product;
612# renaming it renames it; archiving it (active = false) archives it there too.
613[hooks]
614before_create = "apiplant_stripe_product"
615before_update = "apiplant_stripe_product"
616
617[fields.name]
618type = "string"
619required = true
620
621[fields.description]
622type = "text"
623
624# Off the price list without deleting the history that points at it. Stripe
625# calls this "archived", and a product with live subscriptions cannot be
626# deleted in either system — only stopped from being bought again.
627[fields.active]
628type = "boolean"
629default = true
630
631# Whether buying this means posting something to somebody. A mug is
632# shippable; a subscription and a downloadable file are not.
633#
634# One column, two consequences: the checkout asks for a shipping address (from
635# the countries in `[payments] shipping_countries`), and the product is filed
636# under the tangible-goods tax code rather than the digital one. Both follow
637# from the same fact, which is why it is one field and not three.
638[fields.shippable]
639type = "boolean"
640default = false
641
642[fields.shippable.admin]
643help = "Physical goods that have to be posted. Adds a shipping address to the checkout."
644
645# Stripe's tax category for this product — `txcd_10000000` for a digital
646# service, `txcd_99999999` for general tangible goods, and a more specific one
647# where the rate differs (an e-book is not taxed like software in most of the
648# EU). Empty takes the default for its kind from `[payments]`.
649#
650# This is the input to automatic tax that nobody remembers to set, and the one
651# that decides whether the computed rate is right or merely plausible.
652[fields.tax_code]
653type = "string"
654
655[fields.tax_code.admin]
656help = "Stripe tax category. Empty uses the [payments] default for digital or physical."
657
658# Free-form facts the app checks when deciding what a plan may do: seat
659# limits, feature flags, an internal tier name. Copied to Stripe as metadata
660# so an operator reading either system sees the same thing.
661[fields.features]
662type = "json"
663
664[fields.stripe_product_id]
665type = "string"
666unique = true
667
668[fields.stripe_product_id.admin]
669readonly = true
670help = "Filled in by Stripe when the product is first saved."
671"#;
672
673/// One way to pay for a [product](BILLING_PRODUCT_TOML): an amount, a
674/// currency, and either a billing interval (a subscription) or none (a one-off
675/// payment).
676///
677/// Amounts are in the currency's **smallest unit** — 1000 is €10.00 — because
678/// that is the only representation that is exact, and it is what Stripe,
679/// every card network and every accountant's ledger already use. A float here
680/// would be a rounding error waiting for a big enough invoice.
681pub const BILLING_PRICE_TOML: &str = r#"
682[resource]
683name = "billing_price"
684scope = "global"
685timestamps = true
686
687[admin]
688label = "Price"
689plural = "Prices"
690
691[permissions]
692list   = "public"
693read   = "public"
694create = "role:admin"
695update = "role:admin"    # only `active` and presentation: see the hook
696delete = "role:admin"
697
698[hooks]
699before_create = "apiplant_stripe_price"
700before_update = "apiplant_stripe_price"
701
702[fields.product_id]
703type = "reference"
704references = "billing_product"
705required = true
706on_delete = "cascade"      # a deleted product takes its price list with it
707
708[fields.nickname]
709type = "string"            # "Monthly", "Yearly (2 months free)"
710
711# The charge, in the currency's smallest unit: 1000 = €10.00 = $10.00.
712[fields.unit_amount]
713type = "big_int"
714required = true
715
716[fields.unit_amount.admin]
717help = "In the smallest unit of the currency — 1000 is 10.00."
718
719# ISO 4217; empty takes [payments] currency.
720#
721# `case = "upper"` because that is how the standard writes a currency and how
722# an invoice, a price list and an accountant all expect to read it. Stripe
723# accepts either and answers in lowercase, so without this the same currency
724# arrives spelled two ways depending on which row you look at — and `?currency`
725# filters match only whichever the caller guessed.
726[fields.currency]
727type = "string"
728max_length = 3
729case = "upper"
730
731# How often it recurs: "month", "year", "week", "day", or empty for a price
732# that is charged once. This is what decides whether buying it starts a
733# subscription or takes a single payment.
734[fields.interval]
735type = "string"
736
737[fields.interval.admin]
738options = ["|One-off", "day|Daily", "week|Weekly", "month|Monthly", "year|Yearly"]
739
740# Charge every N intervals — 3 with interval = "month" is quarterly.
741[fields.interval_count]
742type = "integer"
743default = 1
744
745# Days before the first charge. 0 (the default) starts billing immediately.
746[fields.trial_days]
747type = "integer"
748default = 0
749
750# Whether the amount already includes tax. "exclusive" (the default) means tax
751# is added on top, which is what [payments] automatic_tax computes; "inclusive"
752# means the amount is the total and the tax is worked out from within it.
753[fields.tax_behavior]
754type = "string"
755default = "exclusive"
756
757[fields.tax_behavior.admin]
758options = ["exclusive|Tax added on top", "inclusive|Tax included in the amount"]
759
760[fields.active]
761type = "boolean"
762default = true
763
764# Stripe prices are immutable once created: changing an amount there means
765# creating a new price and archiving the old one, which is exactly what the
766# hook does. The id therefore points at whichever price object is current.
767[fields.stripe_price_id]
768type = "string"
769unique = true
770
771[fields.stripe_price_id.admin]
772readonly = true
773help = "Filled in by Stripe. Changing the amount creates a new price and archives the old one."
774"#;
775
776/// The organisation as Stripe knows it: one row per tenant, holding the
777/// customer id every charge and subscription hangs off.
778///
779/// Org-scoped and `private`. It is written by the checkout endpoint and the
780/// webhook, never by hand — a second customer for one organisation would
781/// split its payment methods, its invoices and its tax status across two
782/// records that neither system would ever reconcile.
783pub const BILLING_CUSTOMER_TOML: &str = r#"
784[resource]
785name = "billing_customer"
786scope = "organization"
787timestamps = true
788
789[admin]
790label = "Billing customer"
791plural = "Billing customers"
792
793[permissions]
794list   = "role:admin"      # billing is the admins' business
795read   = "role:admin"
796create = "private"
797update = "private"
798delete = "private"
799
800[fields.stripe_customer_id]
801type = "string"
802required = true
803unique = true
804
805# Where invoices and receipts go. Defaults to the address of whoever first
806# started a checkout, and is changed from the Stripe portal.
807[fields.email]
808type = "string"
809max_length = 320
810
811[fields.name]
812type = "string"
813
814# The buyer's VAT/GST number, once they have given one. Kept because it is
815# what turns a taxed sale into a reverse-charge one, and support gets asked
816# about it more than anything else on this table.
817[fields.tax_id]
818type = "string"
819
820# Country Stripe places the customer in, for tax. Two-letter ISO 3166-1.
821[fields.tax_country]
822type = "string"
823max_length = 2
824
825# Everything else Stripe holds about the customer, as last delivered. Kept so
826# an operator can answer a billing question without opening two dashboards.
827[fields.details]
828type = "json"
829
830[fields.details.admin]
831visible = false
832"#;
833
834/// A live (or lapsed) subscription: one organisation paying for one
835/// [price](BILLING_PRICE_TOML) on a schedule.
836///
837/// `private` for writes and readable by any member, which is what makes it
838/// usable as an entitlement check — a function asking "is this org on a paid
839/// plan" reads a row through the same permissions as everything else. Stripe
840/// is the source of truth and the webhook is what writes here: a row edited by
841/// hand would say the customer is paying when they are not.
842pub const BILLING_SUBSCRIPTION_TOML: &str = r#"
843[resource]
844name = "billing_subscription"
845scope = "organization"
846timestamps = true
847
848[admin]
849label = "Subscription"
850plural = "Subscriptions"
851
852[permissions]
853list   = "member"          # members can see what plan they are on
854read   = "member"
855create = "private"         # started by POST /billing/checkout
856update = "private"         # written by the Stripe webhook
857delete = "private"
858
859[fields.price_id]
860type = "reference"
861references = "billing_price"
862on_delete = "set_null"     # an archived price outlives its row
863
864[fields.customer_id]
865type = "reference"
866references = "billing_customer"
867on_delete = "cascade"
868
869# Stripe's own words: "trialing", "active", "past_due", "canceled",
870# "incomplete", "incomplete_expired", "unpaid", "paused". Anything checking
871# for a paying customer wants "active" or "trialing" — see the `payments`
872# guide for why "past_due" is a judgement call and not a status.
873[fields.status]
874type = "string"
875required = true
876
877[fields.quantity]
878type = "integer"
879default = 1
880
881[fields.current_period_end]
882type = "timestamp"
883
884# Set when the customer has asked to stop but has already paid through the end
885# of the period: still entitled, not renewing.
886[fields.cancel_at_period_end]
887type = "boolean"
888default = false
889
890[fields.trial_ends_at]
891type = "timestamp"
892
893[fields.canceled_at]
894type = "timestamp"
895
896[fields.stripe_subscription_id]
897type = "string"
898required = true
899unique = true
900
901[fields.stripe_subscription_id.admin]
902readonly = true
903"#;
904
905/// One payment that happened: a one-off purchase, or an invoice a
906/// subscription generated.
907///
908/// A ledger, not a state machine — a row per attempt Stripe told us about,
909/// kept whether it succeeded or not, because "the card was declined on the
910/// 3rd" is the answer to most billing questions.
911pub const BILLING_PAYMENT_TOML: &str = r#"
912[resource]
913name = "billing_payment"
914scope = "organization"
915timestamps = true
916
917[admin]
918label = "Payment"
919plural = "Payments"
920
921[permissions]
922list   = "role:admin"
923read   = "role:admin"
924create = "private"
925update = "private"
926delete = "private"
927
928[fields.customer_id]
929type = "reference"
930references = "billing_customer"
931on_delete = "cascade"
932
933[fields.price_id]
934type = "reference"
935references = "billing_price"
936on_delete = "set_null"
937
938[fields.subscription_id]
939type = "reference"
940references = "billing_subscription"
941on_delete = "set_null"     # null for a one-off
942
943# What was actually taken, in the smallest unit, and what of it was tax.
944[fields.amount]
945type = "big_int"
946required = true
947
948[fields.tax_amount]
949type = "big_int"
950default = 0
951
952# Upper-cased like the price's, so a payment and the price it paid for read the
953# same — this column is written by the webhook, from a provider that reports
954# lowercase.
955[fields.currency]
956type = "string"
957max_length = 3
958case = "upper"
959
960# "succeeded", "pending", "failed", "refunded".
961[fields.status]
962type = "string"
963required = true
964
965[fields.description]
966type = "string"
967
968# The buyer's own receipt, hosted by Stripe. Worth storing: it is the link
969# support is asked for, and it outlives the session that produced it.
970[fields.receipt_url]
971type = "string"
972max_length = 2048
973
974[fields.paid_at]
975type = "timestamp"
976
977[fields.stripe_payment_intent_id]
978type = "string"
979unique = true
980
981[fields.stripe_invoice_id]
982type = "string"
983"#;
984
985/// Every webhook Stripe has delivered, by its event id.
986///
987/// This table is the idempotency: Stripe retries a delivery until it is
988/// acknowledged and may deliver the same event twice regardless, so the
989/// handler inserts the id first and does the work only if the insert was new.
990/// Without it a retried `invoice.paid` is a second row in the ledger and a
991/// customer who appears to have paid twice.
992///
993/// Entirely `private`, and hidden from the dashboard: it is a log with one
994/// reader, which is the handler itself.
995pub const BILLING_EVENT_TOML: &str = r#"
996[resource]
997name = "billing_event"
998scope = "global"
999timestamps = true
1000
1001[admin]
1002label = "Billing event"
1003plural = "Billing events"
1004visible = false
1005
1006[permissions]
1007list   = "private"
1008read   = "private"
1009create = "private"
1010update = "private"
1011delete = "private"
1012
1013[fields.stripe_event_id]
1014type = "string"
1015required = true
1016unique = true
1017
1018[fields.kind]
1019type = "string"
1020required = true            # "checkout.session.completed", "invoice.paid", …
1021
1022# When the work finished. A row with this null is an event that arrived and
1023# then failed to process — Stripe will retry it, and this is where to look
1024# when it stops retrying.
1025[fields.processed_at]
1026type = "timestamp"
1027
1028[fields.error]
1029type = "text"
1030
1031[fields.payload]
1032type = "json"
1033hidden = true
1034"#;
1035
1036/// The name → embedded-TOML table of built-ins, in dependency order so foreign
1037/// keys resolve (organization and user before membership/api_key/oauth).
1038pub fn builtins() -> Vec<(&'static str, &'static str)> {
1039    vec![
1040        ("organization", ORGANIZATION_TOML),
1041        ("user", USER_TOML),
1042        ("membership", MEMBERSHIP_TOML),
1043        ("membership_role", MEMBERSHIP_ROLE_TOML),
1044        ("api_key", API_KEY_TOML),
1045        ("oauth_connection", OAUTH_TOML),
1046        ("invitation", INVITATION_TOML),
1047        ("auth_token", AUTH_TOKEN_TOML),
1048    ]
1049}
1050
1051/// The billing resources, in dependency order (product before price, customer
1052/// before subscription before payment).
1053///
1054/// Unlike [`builtins`] these are conditional: an app gets them when its
1055/// `[payments]` section names a provider, and not otherwise. Five tables and
1056/// five sets of endpoints is a lot to hand an app that never takes money, and
1057/// unlike `invitation` — which is two columns behind a feature most apps do
1058/// eventually turn on — a catalogue nobody sells from is just noise in the
1059/// dashboard.
1060///
1061/// Migrations are additive, so switching payments *off* leaves the tables
1062/// where they are: the data outlives the config, which is the right way round
1063/// for anything that recorded money changing hands.
1064pub fn billing_builtins() -> Vec<(&'static str, &'static str)> {
1065    vec![
1066        ("billing_product", BILLING_PRODUCT_TOML),
1067        ("billing_price", BILLING_PRICE_TOML),
1068        ("billing_customer", BILLING_CUSTOMER_TOML),
1069        ("billing_subscription", BILLING_SUBSCRIPTION_TOML),
1070        ("billing_payment", BILLING_PAYMENT_TOML),
1071        ("billing_event", BILLING_EVENT_TOML),
1072    ]
1073}
1074
1075/// The one resource an app gets for having an `[oauth]` provider.
1076///
1077/// Conditional for the same reason the billing tables are: `oauth_state` is
1078/// pure machinery, empty except during a sign-in, and an app that signs nobody
1079/// in through a third party has no use for a table in its dashboard that never
1080/// holds a row. `oauth_connection` is *not* here — it is a built-in for every
1081/// app, because "which accounts is this person known by" is a question worth
1082/// having a shape for even before anybody answers it.
1083pub fn oauth_builtins() -> Vec<(&'static str, &'static str)> {
1084    vec![("oauth_state", OAUTH_STATE_TOML)]
1085}
1086
1087/// One published message, waiting for one subscriber to handle it.
1088///
1089/// This table *is* the queue. A `publish` writes a row here and fires a
1090/// `NOTIFY`; a subscriber claims the row, runs the function, and marks it. The
1091/// row is what makes the message durable — a broker-less queue whose messages
1092/// only lived in a notification would lose everything published while nothing
1093/// was listening, and would have nowhere to record that an attempt failed.
1094///
1095/// One row per *subscriber*, not per message: a topic two functions listen to
1096/// produces two rows, so a handler that keeps failing retries on its own
1097/// schedule without dragging its neighbour along. A message nobody subscribes
1098/// to still gets a row, marked handled with no subscriber — the answer to "I
1099/// published it, why did nothing happen?" is then a row rather than a silence.
1100///
1101/// Readable by an admin and writable by nobody: the columns are a state machine
1102/// the subscriber owns, and editing one by hand is how a message gets handled
1103/// twice or never.
1104pub const QUEUE_MESSAGE_TOML: &str = r#"
1105[resource]
1106name = "queue_message"
1107scope = "global"
1108timestamps = true
1109
1110[admin]
1111label = "Background task"
1112plural = "Background tasks"
1113
1114# "System" is a reserved group: the dashboard lists it down with the other
1115# screens every app has whether or not it uses the feature — your account, the
1116# team, the organization — rather than as a heading of its own among the app's
1117# own resources. An app that never publishes a message still gets this table
1118# (it is a built-in), and a top-level "Operations" section holding one
1119# permanently empty list is the sort of thing that makes a dashboard look like
1120# it belongs to somebody else's app.
1121group = "System"
1122
1123# Spelled out rather than left to the defaults, because the questions this
1124# screen answers are asked in a hurry: what is stuck, on which topic, for whom,
1125# and how many times has it tried. The generic guess — first string column —
1126# would name a row by `claimed_by`, which is the one column that is usually
1127# empty.
1128display_field = "topic"
1129columns = ["topic", "subscriber", "status", "attempts", "available_at", "processed_at"]
1130
1131# `error` is in here because the way somebody arrives at this table is usually
1132# with half a message from a log line and no idea which row it came from.
1133search_fields = ["topic", "subscriber", "error"]
1134
1135# Visible and readable, because the questions this table answers — what is
1136# stuck, what failed, what is retrying — are asked by a person under time
1137# pressure, and making them write SQL for it is not a kindness. Nothing here is
1138# writable over the API: the subscriber owns these columns.
1139[permissions]
1140list   = "role:admin"
1141read   = "role:admin"
1142create = "private"
1143update = "private"
1144delete = "private"
1145
1146[fields.topic]
1147type = "string"
1148required = true
1149
1150# The function this row is for. Empty means the message was published to a
1151# topic nothing subscribes to; the row is kept as the record that it happened.
1152[fields.subscriber]
1153type = "string"
1154
1155# pending → running → done, or → failed once the attempts run out. A `pending`
1156# row whose `available_at` is in the future is waiting out a retry backoff; a
1157# `running` row whose `claimed_at` is older than `[queues] lease_secs` belongs
1158# to a subscriber that died, and is taken back on the next sweep.
1159[fields.status]
1160type = "string"
1161required = true
1162default = "pending"
1163
1164[fields.status.admin]
1165options = ["pending|Pending", "running|Running", "done|Done", "failed|Failed"]
1166
1167[fields.payload]
1168type = "json"
1169required = true
1170
1171[fields.attempts]
1172type = "integer"
1173required = true
1174default = 0
1175
1176# The earliest this row may be claimed. Set on publish (now) and pushed forward
1177# by each failure, which is how the retry backoff is expressed without a timer
1178# living in any one process.
1179[fields.available_at]
1180type = "timestamp"
1181required = true
1182
1183# When the current attempt started, and which process took it. Together with
1184# `[queues] lease_secs` these are what let a message survive the subscriber
1185# that was holding it: a `running` row nobody has finished within the lease is
1186# assumed abandoned and offered again.
1187[fields.claimed_at]
1188type = "timestamp"
1189
1190[fields.claimed_by]
1191type = "string"
1192
1193[fields.processed_at]
1194type = "timestamp"
1195
1196# Why the last attempt failed. Kept on a row that later succeeds, because "it
1197# worked on the third try" is worth knowing.
1198[fields.error]
1199type = "text"
1200
1201# Who published it: a principal id, or empty when the publisher was the server
1202# itself (a resource `[publish]` declaration, or a function with no caller).
1203[fields.published_by]
1204type = "string"
1205"#;
1206
1207/// The one resource an app gets for using queues.
1208///
1209/// Unconditional, unlike the billing tables: `[queues]` needs no configuration
1210/// to be useful — a function calling `publish` works in an app whose `main.toml`
1211/// never mentions queues at all — so the table has to be there before anyone
1212/// declares anything. It costs one empty table.
1213pub fn queue_builtins() -> Vec<(&'static str, &'static str)> {
1214    vec![("queue_message", QUEUE_MESSAGE_TOML)]
1215}
1216
1217/// Parse one built-in by its embedded TOML. Panics on a malformed built-in —
1218/// that would be a bug in this crate, caught by the test below.
1219pub fn parse_builtin(toml_src: &str) -> Resource {
1220    let r: Resource = toml::from_str(toml_src).expect("built-in resource TOML is valid");
1221    r.validate().expect("built-in resource is valid");
1222    r
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227    use super::*;
1228
1229    #[test]
1230    fn all_builtins_parse() {
1231        for (name, src) in builtins()
1232            .into_iter()
1233            .chain(billing_builtins())
1234            .chain(oauth_builtins())
1235            .chain(queue_builtins())
1236        {
1237            let r = parse_builtin(src);
1238            assert_eq!(r.meta.name, name);
1239        }
1240    }
1241
1242    /// The prefix is what keeps a shop's own `product` resource out of the
1243    /// framework's way, so it holds for every billing resource, not just the
1244    /// two that would have collided today.
1245    #[test]
1246    fn every_billing_resource_is_namespaced() {
1247        for (name, _) in billing_builtins() {
1248            assert!(name.starts_with("billing_"), "`{name}` is unprefixed");
1249        }
1250    }
1251
1252    /// A `reference` that names a resource nothing declares migrates to a
1253    /// foreign key against a table that isn't there, and the app fails to
1254    /// boot. Every target here is either a billing resource or a core one.
1255    #[test]
1256    fn billing_references_resolve_within_the_app() {
1257        let known: Vec<&str> = builtins()
1258            .into_iter()
1259            .chain(billing_builtins())
1260            .map(|(name, _)| name)
1261            .collect();
1262        for (name, src) in billing_builtins() {
1263            let resource = parse_builtin(src);
1264            for (field, spec) in &resource.fields {
1265                if let Some(target) = &spec.references {
1266                    assert!(
1267                        known.contains(&target.as_str()),
1268                        "{name}.{field} points at an unknown `{target}`"
1269                    );
1270                }
1271            }
1272        }
1273    }
1274}