Isabelle is a Rust-based framework for building safe and performant servers for the variety of use cases.
- Unified item storage with addition, editing and deletion support.
- Collection hooks allowing plugins to do additional checks or synchronization.
- Security checks.
- E-Mail sending support.
- Google Calendar integration.
- Login/logout functionality.
- One-time password support.
- Signing in with Google and Apple.
- Signing in against an LDAP directory.
- Self-describing HTTP API: an OpenAPI 3.1 document generated from the running deployment, plugin routes included.
- Feature gate declared on disk in
features.js, readable over the API and writable only by whoever installs the deployment.
The full HTTP surface of a running deployment is described by an OpenAPI 3.1 document the server generates on request:
GET /openapi.json— the document itself.GET /docs— the same thing rendered as a static page (no scripts, no external assets, works offline).
Both are public. A description is not a credential: every endpoint it names
still authenticates its own callers, so publishing it opens nothing. It does
say which plugin routes exist and which collections the store holds — a
deployment that would rather not publish that starts core with
--openapi-private, which serves both to administrators only.
It is generated rather than committed because half of the surface only exists
at runtime: each extra_route / extra_unprotected_route /
extra_rest_route in internals.js becomes a real path at startup, and the
collection parameter is constrained to the collections the store actually
holds. The short list below covers the endpoints core itself always has.
- GET /is_logged_in: check the login status.
Result:
{
"username": "<username>",
"id": <user id>,
"role": [ "role_is_admin" ],
"site_name": "Test",
"site_logo": "Test Logo"
"licensed_to": "Test Company"
}- POST /login (username, password inside the post request):
{
"succeeded": true/false,
"error": "detailed error",
}-
POST /logout:
-
GET /itm/list (collection, [id], [id_min], [id_max], [skip], [limit], [sort_key], [filter]): read the item from the collection
{
"map": [ <id>: {} ],
"total_count": <value>
}- POST /itm/edit ("item" inside the post request and inside the query string, "collection" and "merge" = false/true in query): edit the item in collection.
{
"succeeded": true/false,
"error": "detailed error",
}- POST /itm/del (collection, id): delete the item from the collection
{
"succeeded": true/false,
"error": "detailed error",
}Core speaks the OpenID Connect authorization-code flow, so an account can be signed into with a Google or Apple identity instead of a password. Nothing is enabled by default: a provider exists for a deployment exactly when its entry exists in the encrypted secret store, and there is no second switch to leave in the wrong position.
GET /auth/config and POST /auth/config are the administrator-only pair for
this (proteos puts a form on them under Settings → Sign-in). The read returns
each provider's public settings and whether a secret is stored — never the
secret itself. The write takes only the fields being changed, and checks the
result before storing it, so an unreadable Apple key is refused at the moment
someone saves it rather than weeks later at a token endpoint. POST /auth/config/forget removes a provider, which is also how it is switched off.
Because a client secret can never be read back, an empty one in a write means "leave what is stored alone" — an empty box on a screen that was never allowed to show the value is not a decision to delete it.
Underneath, each provider is one entry in the encrypted secret store, so
POST /secret/edit works too and is what a script would use.
Google — the entry is named oauth_google:
| key | what it is |
|---|---|
client_id |
the OAuth client ID from the Google Cloud console |
client_secret |
its secret |
redirect_uri |
optional; see below |
Apple — the entry is named oauth_apple. Apple issues no client secret;
it wants a short-lived JWT signed with a key you register, so what is stored
is the key itself:
| key | what it is |
|---|---|
client_id |
the Services ID, e.g. io.example.web |
team_id |
the Apple developer team |
key_id |
the identifier of the Sign in with Apple key |
private_key |
the contents of its .p8 file, PEM and all |
redirect_uri |
optional; see below |
This is the address the browser comes back to, and it has to be registered
with the provider character for character. Left unset, core uses
<--pub-url>/api/auth/<provider>/callback — where the shipped nginx
configuration puts it. A deployment that serves core somewhere else sets
redirect_uri explicitly. Apple will not register an http:// address at
all, so Apple sign-in needs TLS even in development.
GET /auth/providers— what a login screen should offer. No session needed, and it says only that a provider is configured, never with what.GET /auth/config,POST /auth/config,POST /auth/config/forget— reading and writing the above. Administrators only.GET /auth/{provider}/start?next=/where— a redirect to the provider.GET|POST /auth/{provider}/callback— where the provider returns the browser. On success a session cookie is issued and the browser goes tonext; otherwise to the same place withauth_errorset to one ofdenied,unverified,registration_closed,inactive,mismatchedorfailed. The detail behind a refusal is logged rather than put in a URL.
A directory is not a provider: there is no redirect and no button. People type
their username and password into the ordinary form, and the password is
checked by binding to the directory with it. So it hangs off /login, and it
is tried only when the local check has not already let them in — which means a
deployment that has always worked keeps working whatever state the directory
is in, and an administrator with a password here can still get in when the
directory is the thing that is broken.
It is configured through the same /auth/config with provider: "ldap", and
stored as one secret-store entry named ldap:
| key | what it is |
|---|---|
url |
ldaps://directory.example.com, or ldap:// |
base_dn |
where to search |
user_filter |
how to search, %u standing for what was typed — (uid=%u), (sAMAccountName=%u) |
user_dn_template |
the other shape: build the DN and bind straight to it, uid=%u,ou=people,dc=example,dc=com |
bind_dn |
the service account that does the searching; blank searches anonymously |
bind_password |
its password |
email_attribute |
default mail |
name_attribute |
default cn |
allow_plaintext |
required before ldap:// will be accepted |
Fill in either a base DN with a filter, or a DN template. An entry with no address cannot be signed in: an account here is an email address, and there would be nothing to make one out of.
Three things are refused on purpose. A blank password never reaches the
directory, because a bind with a DN and no password is an anonymous bind
that succeeds and proves nothing. Values interpolated into a DN or a filter
are escaped, so a username of * is a username and not every entry in the
tree. And ldap:// has to be asked for in as many words, because every
password typed crosses that connection in clear.
The identity, and nothing else — who the account is and what address it has. Roles and activity belong to the record here, so no provider can make anyone an administrator or revive a disabled account.
The same policy governs a directory sign-in, with the directory as the thing
that vouched. An address the source will not vouch for is not an identity, and
is refused. A verified one signs into the account that already holds it; if
there is none, one is created when allow_self_registration permits it. The
provider's own identifier for the account is remembered on first sign-in, and
a later sign-in presenting a different one for the same address is refused —
so an address that changes hands at the provider cannot pick up the record
left behind.
Which features a deployment has is an installation decision, so it is written
in a file and nowhere else. features.js lives in the data directory, beside
settings.js and internals.js, and core only ever reads it. There is no
/feature/edit to go with /setting/edit: no session, no API token and no
plugin can grant a feature that was not granted on disk, and its absence from
the API is the guarantee rather than a gap.
It is a JSON object. Each key is a feature name; each value is that feature's descriptor, in whatever shape the feature wants:
{
"reports": { "formats": ["pdf", "csv"], "retention_days": 90 },
"sso": { "tenant": "acme" },
"beta_ui": null
}Core interprets exactly one thing here — the set of keys. Descriptors are carried through untouched, so there is no schema to violate and no format to migrate; a feature's own code is the only thing that has to understand its descriptor.
GET /feature/list— the declared names, sorted, as a JSON array. Any signed-in caller: the UI a normal user looks at is the main thing that needs to know which features exist. An API token needs thereadscope.
Descriptors are not served. They can hold quotas, tenant names and licence
detail that the feature's implementation needs and a browser does not, so they
stay on the server: core reads them through Data::features(), and a plugin
asks for them over the plugin channel — CoreHandle::features_all(), with
features_get, features_has and features_list beside it. Nothing in
either direction writes them.
The file is read once, at startup, exactly as internals.js is — an operator
who edits it restarts core, and until then every reader in the process agrees
about what the deployment may do.
Two consequences worth stating plainly, both deliberate:
- A deployment with no
features.jsdeclares no features. Empty is the safe direction for a list that says what is permitted — unlike an emptysettings.js, which means defaults. A missing file is logged as a warning and an unparseable one as an error, because those are the two ways a deployment that should have features ends up with none. - Updates must leave the data directory alone. Core never writes
features.js, so nothing inside the server can lose it; what can is an update script (--update-script,POST /system/update) that replaces more than the binary. The same already applies tosettings.js,internals.js,secrets.encand the key files, so a script that is safe for those is safe for this.
- Python 3 is needed for Google Calendar integration
Building Isabelle is as easy as Cargo invocation:
cargo buildUse run.sh script:
./run.shMIT