Skip to content

Showing another app's records

Sam runs a web shop, and its customers’ questions land in the company’s service desk, a Protobase app of its own. When a customer calls about an order, Sam’s people look the order up in the shop’s admin app, then open the desk in another tab to find the customer’s ticket. They want the tickets in the shop’s admin app, next to the orders: a list, each ticket’s page, the open ones in the sidebar, and a status they can change. The desk’s agents keep working in the desk.

This guide makes the desk’s tickets a remote resource of the shop: the shop draws them with its own pages, while the desk keeps the data, the rules and the views. Nothing is copied and nothing is proxied: the shop’s admin app reads the desk’s API with the shop’s own sign-in.

You need two Protobase projects, each with its own database, its own admin store and a user: the from-scratch preset (examples/scratch in the repository) is enough for both. Each runs on its own origin; this guide runs them side by side on one machine:

BETTER_AUTH_URL in its .env Started with
The service desk http://localhost:4720 protobase dev --port 4720
The shop http://localhost:4710 protobase dev --port 4710

BETTER_AUTH_URL matters here: it is the issuer of each app’s tokens, and the desk will recognise the shop’s tokens by it.

The shop has its orders. The desk has tickets and their notes:

config/tickets/data.ts
import { f, resource } from '@protobase/schema'
type Caller = { user: { roles: string[] } }
const deskOnly = (ctx: unknown) => (ctx as Caller).user.roles.includes('admin')
export const tickets = resource('tickets')
.table('tickets')
.fields({
id: f.integer().readOnly().filterable().sortable(),
title: f.text().filterable().sortable(),
customer_email: f.text().filterable(),
status: f.enum(['open', 'waiting', 'closed']).filterable().sortable(),
updated_at: f.timestamp().readOnly().filterable().sortable(),
})
.primaryKey((r) => r.id)
.search((r) => [r.title, r.customer_email])
.access({ delete: deskOnly })
export const ticketNotes = resource('ticket_notes')
.table('ticket_notes')
.fields({
id: f.integer().readOnly().filterable().sortable(),
ticket_id: f.relation('tickets').filterable(),
body: f.text(),
created_at: f.timestamp().readOnly().sortable(),
})
.primaryKey((r) => r.id)
.access({ delete: deskOnly })

Only the desk’s own admins delete: the access rule holds for everyone who reaches the desk’s API, the shop’s people included.

config/tickets/ui.ts
import { l, view } from '@protobase/schema'
import type { ticketNotes, tickets } from './data'
const recent = { status: 'status', tones: { open: 'warning', waiting: 'neutral', closed: 'success' }, orderBy: 'updated_at desc' } as const
export const ticketsView = view<typeof tickets>('tickets')
.title((r) => r.title)
.names({ singular: 'Ticket', plural: 'Tickets' })
.nav({ group: 'Support', recent })
.fields((r) => ({ customer_email: r.customer_email.label('Customer'), status: r.status.format('badge'), updated_at: r.updated_at.label('Changed').format('relative') }))
.list((r) => ({ columns: [r.title, r.customer_email, r.status, r.updated_at], sort: [[r.updated_at, 'desc']] }))
.filters((r, w) => [w.facets(r.status)])
.layout((r) => [
l.section('Ticket', [r.title, r.customer_email, r.status]),
l.related('Notes', { resource: 'ticket_notes', field: 'ticket_id', sort: 'created_at desc', columns: ['body', 'created_at'], empty: 'No notes yet.' }),
])
export const ticketNotesView = view<typeof ticketNotes>('ticket_notes').nav({ hidden: true })

That is the desk’s admin app as its agents use it. Nothing in it knows about the shop yet.

The shop’s admin app will call the desk’s API from the browser, with the token the shop gave the signed-in person. The desk needs to accept two things: that token, and calls from the shop’s origin.

The token: list the shop as a trusted app in the desk’s authenticator, and say what the shop’s roles are at the desk:

auth/auth.ts
export const authenticate = betterAuthAuthenticator({
auth,
tenant: 1,
// The shop's admins sign in at the shop; the desk takes the shop's tokens and calls them `shop` here.
apps: [{ issuer: 'http://localhost:4710', roles: { admin: 'shop' }, tenant: 1 }],
})

The desk now checks a token from the shop against the shop’s keys, at http://localhost:4710/api/auth/jwks. Its holder is shop at the desk, never the desk’s admin, so the deskOnly rule keeps tickets from being deleted from the shop; a shop user with any other role is refused. The reference has the details, such as the ids those users get.

The origin: the shop’s admin app runs on http://localhost:4710, so the desk answers CORS for it:

protobase.config.ts
import * as config from './config'
import { auth, authenticate } from './auth/auth'
// The shop's admin app, on its own origin, calls this API from the browser.
export default { config, auth, authenticate, options: { cors: ['http://localhost:4710'] } }

protobase dev reloads the desk.

The shop lists the desk as a source, with the resources it draws from it:

protobase.config.ts
import * as config from './config'
import { auth, authenticate } from './auth/auth'
export default {
config,
auth,
authenticate,
options: {
sources: [{ url: 'http://localhost:4720/api/v1', label: 'Service desk', resources: ['tickets', 'ticket_notes'] }],
},
}

url is the desk’s resources API, and label its name when the shop’s people cannot reach it. The notes are there because a ticket’s page lists them: what the shop does not draw, it does not show. A platform that runs the shop can set the same list in PROTOBASE_SOURCES, without touching the shop’s code:

Terminal window
PROTOBASE_SOURCES='[{"url":"http://localhost:4720/api/v1","label":"Service desk","resources":["tickets","ticket_notes"]}]'

Had the shop a resource or page called tickets of its own, it would now refuse to start, naming both: /tickets can only be one thing.

Sign in to the shop. The sidebar has a Support group with Tickets and the desk’s recent tickets under it, each with its status; /tickets is the desk’s list, with its columns, its status filter and its search, and the breadcrumbs read “Support › Tickets”:

The desk's tickets in the shop · open in Storybook

A ticket opens at /tickets/1, with its notes from the desk, and the trail back to the list. Change the status and Save: the shop’s admin app writes it to the desk with the ticket’s ETag, under the desk’s rules, and the ticket moves in the sidebar. The global search in the top bar finds tickets too.

A ticket's page in the shop · open in Storybook

(The stories run on made-up data, with a summary section the next section describes.)

Behind it, the shop’s admin app read the desk’s /api/meta with the shop’s token, kept the tickets and notes, and placed them as the desk’s view says. Every list and save went straight from the browser to the desk.

“Support” is how the desk’s agents think of tickets. In the shop they are about customers, and only the open ones matter in the sidebar. The desk decides what the shop’s people see, so it gives the role it gave them, shop, a view of its own:

config/tickets/ui.ts
// What the shop's people see: the tickets of their customers, next to the shop's orders, and the open ones first.
export const shopTicketsView = ticketsView
.forRoles(['shop'])
.names({ singular: 'Customer ticket', plural: 'Customer tickets' })
.nav({ group: 'Sales', recent: { ...recent, filter: 'status != "closed"' } })

After a reload, “Customer tickets” sits in the shop’s own Sales group, under Orders, and its recent tickets are the ones still open. The desk’s agents still see Support › Tickets.

Stop the desk and reload the shop. The orders work as before. The tickets stay in the sidebar, under “Service desk” and marked, and /tickets says what is wrong, with a way to try again:

The desk does not answer · open in Storybook

Start the desk again and press Try again: the tickets are back. The same happens when the desk answers but does not accept the shop’s sign-in, for instance when its apps does not list the shop, with the desk’s own reason.

The desk’s views can name custom components, such as a timeline of the ticket with l.custom(TicketTimeline). The shop draws them by name from its plugins, so the desk ships them as a plugin, built with protobase build-plugin and served wherever the shop’s browser can load it, and the shop lists it with the source, for instance:

Terminal window
PROTOBASE_PLUGINS='http://localhost:4720/plugins/desk/plugin.json'

Inside, the component reads the ticket with useLayoutRecord(), and useList and useRecord read the desk for the desk’s resources. Without the plugin, the shop draws the component’s fallback in its place and everything else of the ticket.

  • Remote resources: how it works, the contract between an app and its sources, and the API.
  • Your first plugin: build the plugin a source’s views need.
  • Access: what the role you give another app’s users may do.