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