1use crate::schema::Resource;
25
26pub 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
72pub 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
110pub 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
150pub 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
230pub 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
285pub 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
337pub 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
372pub 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
465pub 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
569pub 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
673pub 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
776pub 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
834pub 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
905pub 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
985pub 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
1036pub 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
1051pub 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
1075pub fn oauth_builtins() -> Vec<(&'static str, &'static str)> {
1084 vec![("oauth_state", OAUTH_STATE_TOML)]
1085}
1086
1087pub 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
1207pub fn queue_builtins() -> Vec<(&'static str, &'static str)> {
1214 vec![("queue_message", QUEUE_MESSAGE_TOML)]
1215}
1216
1217pub 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 #[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 #[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}