Skip to main content

Yellow Pages Schema — the Entity Model

The Yellow Pages (yp) database is the central registry of a Drumee installation. While each user and workspace lives in its own sharded database, yp is the single directory that answers "what exists, who owns it, which host serves it, and what is this hostname?" — the map every request is resolved against before dispatch.

This page describes the core tables of that registry and how they fit together.

Source of truth

These tables are defined in the schemas repository under yellow_page/tables/. This page reflects the live (British-spelled) tables; see Legacy twins for the deprecated duplicates you may still encounter.

The central idea: entity is a polymorphic supertype

Drumee uses single-table inheritance keyed by a shared id. The entity table holds one row per thing that exists — a user, a workspace, an organization. The subtype tables (drumate, hub, organisation) reuse the same 16-character id as their own identity, and entity.type records which subtype a row is.

To resolve any object generically you join through entity; entity.type tells you which subtype table holds the rest of its data.

Request resolution flow

A request arrives at a hostname and is resolved through the registry before any application code runs:

Access-control hierarchy: node → domain → drumate → hub

Drumee is built to be self-hosted, so access control is organized as four nested tiers. Each tier is governed by a different table, and each is physically contained by the one above it:

Containment ⇒ governance: the node holds one or more domains/organisations; each domain holds the drumates that share its domain_id; privileged drumates own the hubs inside that domain. The governing table changes at each boundary — remitprivilegeprivilege (admin tier) → per-hub permission.

The same relationship as a top-down tree, showing each tier's governing table:

Default and additional domains behave identically — the only difference is domain_id (and, for additional ones, the subdomain FQDN). So both flow into the same drumate and hub tiers:

① Node — the whole instance (remit)

The top of the hierarchy is the node: the deployed Drumee instance itself. Node-wide rights (platform administration, casting/mimicking other users, cross-domain operations) are governed by remit. In practice the authoritative value lives in the drumate.remit column (tinyint), read at request time by the get_remit(uid) SQL function and tested bitwise in server-core/lib/acl.js (check_remit) for services declaring scope: "plateform". The remit bit tiers are defined in server-essentials/lib/lex/remit.js (root, dom_owner, dom_admin, … down to dom_member).

There is also a standalone yp.remit table (method → level, a bit(3) per service method) intended as a method-to-required-level map, but no stored procedure or service currently queries it — the live node check reads drumate.remit. Treat the table as legacy/aspirational (see its entry below).

② Domain / organisation (yp.privilege)

A node hosts at least one organisation, attached to the default domain (domain_id = 1). Additional organisations get their own domain with domain_id > 1, addressed by a subdomain — vhost.fqdn = subdomain.main.domain. The acting domain is resolved either from the request URL (via vhostget_hub) or from a domain_id passed in the query. Access within a domain is governed by yp.privilege — the per-user, per-domain bitmask.

③ Drumates within a domain

All users of an organisation share the same domain_id (see Hierarchical structure below). They are administered by the drumates who hold an admin-tier privilege in that domain, who can grant/revoke membership and rights via domain_grant.

④ Hub (per-DB permission)

A drumate with sufficient domain privilege can create a hub, becoming its owner. Access to a hub is not governed by yp — it is governed by the permission table inside the hub's own sharded database (see Database Sharding), evaluated per MFS node by the shard-local acl_check procedure. This is the tier the default (no-scope) ACL path exercises.

TierScope value in ACLGoverning tableCheck path
① Nodeplateformdrumate.remit (+ legacy yp.remit)get_remit(uid) & mask
② Domaindomainyp.privilegedomain_permission(uid, dom_id, mask)
③ Drumateyp.privilege (admin tier)membership via domain_grant
④ Hub(none) / hubper-hub-DB permissionshard acl_check

Tables

vhost — hostname → entity/domain routing

The DNS/HTTP entry point. Maps an inbound fully-qualified domain name to the entity that serves it and the domain it belongs to. It sits in front of the supertype model.

ColumnTypeNotes
sys_idint unsigned AUTO_INCREMENTInternal PK
fqdnvarchar(256) ascii, UNIQUEThe hostname served (e.g. a custom domain)
idvarchar(16) ascii, UNIQUEentity.id
dom_idint unsigned (default 1)domain.id

Complements the denormalized entity.vhost column: the vhost table is the authoritative many-FQDN-to-one-entity lookup.

domain — the tenant boundary

The hub of the relational model. Nearly every other table carries a dom_id/domain_id pointing here.

ColumnTypeNotes
idint AUTO_INCREMENTPK
namevarchar(1000), UNIQUE (HASH)Domain name

entity — the universal object

One row per thing that exists. Holds the discriminator, the visibility zone, and the physical routing columns that map a logical object to its actual database and filesystem host.

ColumnTypeNotes
idvarchar(16) asciiPK — the identity every subtype shares
typeenumorganization, hub, drumate, shop, blog, forum, guest, dummy
areaenumVisibility zone: public, share, limited, restricted, private, personal, system, dmz-*, pool, template, …
db_namevarchar(255) ascii, UNIQUEThe entity's own sharded database
db_host, fs_hostvarchar(255)Which DB / filesystem host serves it
home_dirvarchar(512), UNIQUEMFS storage root
home_idvarchar(16), UNIQUERoot node in the MFS tree
dom_idint unsigneddomain.id (the domain varchar column is a denormalized copy)
statusenumactive, frozen, deleted, archived, system, locked, online, offline, hidden
spacefloatStorage usage
settingsmediumtextJSON blob (FULLTEXT indexed)
ctime, mtimeint unsignedUnix timestamps
Deprecated columns

homepage and layout (and the home_layout region) are explicitly marked TO BE REMOVED in the schema — do not build on them.

drumate — a user (entity subtype)

The user record. Its id equals the corresponding entity.id. The profile JSON column is the source of truth; almost every readable field is a VIRTUAL generated column derived from it.

ColumnTypeNotes
sys_idint unsigned AUTO_INCREMENTInternal PK
idvarchar(16) ascii, UNIQUE= entity.id
usernamevarchar(80)Unique per domain via (username, domain_id)
domain_idint unsigneddomain.id
remittinyintNode-level access tier (see remit); read by get_remit() for plateform-scope services
profilelongtext JSON (json_valid CHECK)Source of truth — writes go here
firstname, lastname, fullnameVIRTUALFrom profile (fullname falls back to email)
avatar, lang, email, dmail, quotaVIRTUALProjections of profile
allow_searchVIRTUALFrom $.privacy.visibility
otp, connectedVIRTUALFrom profile

email and id are unique. Note the usage/limit split: entity.space is usage, drumate.quota (from profile JSON) is the limit.

hub — a collaborative workspace (entity subtype)

A workspace, addressed by hubname. Its id equals the corresponding entity.id.

ColumnTypeNotes
sys_idint unsigned AUTO_INCREMENTInternal PK
idvarchar(16) ascii, UNIQUE= entity.id
hubnamevarchar(80), UNIQUEAddressable slug
owner_idvarchar(16) asciidrumate.id
origin_idvarchar(16)Source hub when cloned/shared
serialint unsignedUnique per owner via (owner_id, serial)
permissiontinyint unsignedDefault bitwise ACL for the hub
name, description, keywordstextFULLTEXT index on (name, keywords)
profilemediumtextJSON
domain_idint unsigneddomain.id

organisation — per-domain tenant configuration

Effectively a 1:1 settings record per domain (domain_id is UNIQUE). Holds the tenant's security and directory policy. Its id is an entity.id of type organization.

ColumnTypeNotes
sys_idint unsigned AUTO_INCREMENTInternal PK
idvarchar(16) ascii, UNIQUE= entity.id
domain_idint, UNIQUEdomain.id (1:1)
owner_idvarchar(16) ascii, UNIQUEdrumate.id
linkvarchar(1024), UNIQUEThe org's public URL
identvarchar(80)Unique per domain via (ident, domain_id)
password_level, double_auth, usb_authint/flagsAuthentication policy
dir_visibility, dir_infovarchar(40)Directory-listing controls
metadatalongtext JSONExtra config

privilege — per-user, per-domain rights

Maps a user to a bitwise privilege scoped to a domain.

ColumnTypeNotes
sys_idint unsigned AUTO_INCREMENTInternal PK
uidvarchar(16) ascii, UNIQUEdrumate.id (one row per user)
domain_idint unsigneddomain.id
privilegeint unsignedThe permission bitmask (see ACL system)
is_authoritativetinyintWhether this record is authoritative

remit — node-level access map (legacy)

The node-tier counterpart to privilege: a lookup of service method → required level.

ColumnTypeNotes
methodvarchar(255), UNIQUEService method name
levelbit(3)Required node-level tier
Not wired up

No stored procedure or service queries this table. The live node-level check reads the drumate.remit column via get_remit(uid) and tests it bitwise (server-core/lib/acl.jscheck_remit) for scope: "plateform" services. Treat the remit table as legacy/aspirational; the authoritative node privilege is the drumate.remit column, with tiers from server-essentials/lib/lex/remit.js.

Hierarchical structure: how drumates belong to an organisation

Drumee has no explicit "membership" join table between users and organisations. Instead the hierarchy is expressed entirely through a single shared key — domain_id — that threads through every table. The domain row is the pivot; the organisation sits 1:1 on top of it; and every user, entity, and hostname that shares that domain_id is, by definition, part of that organisation.

The membership rule is simply:

A drumate belongs to an organisation iff drumate.domain_id == organisation.domain_id.

Because organisation.domain_id is UNIQUE, a domain maps to exactly one organisation, so domain_id unambiguously identifies both the tenant and the org.

domain_id across the tables

TableColumnMeaning within the hierarchy
domainid (PK)The pivot — the tenant boundary
organisationdomain_id (UNIQUE)The org config, 1:1 on the domain
drumatedomain_idWhich org the user is a member of
entitydom_idThe user/hub/org's entity row, tagged with its tenant
vhostdom_idWhich tenant an inbound hostname resolves to
privilegedomain_idThe user's rights within that org ((uid, domain_id))

Every scoped query on a tenant is therefore a WHERE domain_id = ? (or dom_id = ?) filter — listing an org's members is SELECT * FROM drumate WHERE domain_id = ?, and the cross-domain isolation check is just an equality test on this key.

Binding a drumate: the domain_grant pivot

Membership changes go through one procedure, domain_grant, which keeps every table's domain_id/dom_id in sync atomically. Creating an organisation (server-team/service/private/organization.js#add) shows the full sequence:

let domain = await this.yp.await_proc('domain_create', ident);            // 1. create the pivot
await this.yp.await_proc('domain_grant', domain.id, Remit.dom_owner, this.uid, 1); // 2. bind owner
recds.domain_id = domain.id;
org = await this.yp.await_proc('organisation_add', this.uid, name, domain.name, ident, domain.id, ...); // 3. 1:1 org

domain_grant (yellow_page/procedures/domain/domain_grant.sql) rebinds the user into the domain by touching four tables at once:

INSERT IGNORE INTO privilege (uid, privilege, domain_id) VALUES (_uid, _privilege, _domain_id)
ON DUPLICATE KEY UPDATE privilege = _privilege, domain_id = _domain_id; -- rights in the org
UPDATE drumate SET domain_id = _domain_id WHERE id = _uid; -- membership
UPDATE vhost SET dom_id = _domain_id WHERE id = _uid; -- hostname routing
UPDATE entity SET dom_id = _domain_id WHERE id = _uid; -- entity tenant tag

So a single domain_grant(org_domain_id, privilege, uid) call is what moves a user into an organisation — it simultaneously sets their membership (drumate), their rights (privilege), their entity's tenant tag (entity), and their hostname's tenant (vhost). The first call (with Remit.dom_owner) binds the founder; later calls with lower privilege tiers (e.g. dom_member) add ordinary members to the same domain_id.

Two privilege bases coexist

Notice the founder is granted Remit.dom_owner — domain/org privileges use the cumulative remit tiers (dom_owner, dom_admin, dom_admin_security, dom_member), distinct from the per-node hub permission bits. See the two-permission-maps warning above for why that distinction matters.

Relationship summary

FromColumnToMeaning
vhostidentity.idHostname belongs to entity
vhostdom_iddomain.idHostname's tenant
entitydom_iddomain.idEntity's tenant
drumateidentity.idUser is an entity
hubidentity.idWorkspace is an entity
organisationidentity.idOrg is an entity
drumatedomain_iddomain.idUser's tenant
hubowner_iddrumate.idWorkspace owner
organisationdomain_iddomain.id1:1 tenant config
privilegeuiddrumate.idUser's rights

How privilege is enforced

The privilege table is consulted for services that operate at domain scope — tenant-wide operations rather than per-file actions. Tracing one such request end to end shows how a symbolic permission name in an ACL file becomes a bitwise check against yp.privilege.

1. The ACL declaration

Each backend declares permissions per module.method in acl/*.json. A domain-scoped service looks like this (server-team/acl/mfs.json):

"server_export": {
"scope": "domain",
"permission": { "src": "delete" }
}

scope: "domain" routes the check to the domain path; src is a symbolic permission name, not a number.

2. Symbolic → numeric resolution (at dispatch)

The REST router resolves the symbolic name to a bitmask when it looks up the service — not in the core ACL engine, which only ever sees numbers:

// server-team/router/rest/index.js — Acl.getModule()
if (permission.src) permission.src = permissionValue(permission.src);
if (permission.dest) permission.dest = permissionValue(permission.dest);
permission.scope = scope;

permissionValue() (from @drumee/server-essentials, lib/lex/permission.js) maps the name to a bit via a small lookup:

function value(k, def){
if (typeof(k) === 'number') return k; // idempotent — numbers pass through
return def[k] || 0; // unknown name → 0 → fail-closed (deny)
}

This is idempotent (safe to run on every request against the cached module map) and fail-closed (an unrecognized name resolves to 0, which no bitmask can satisfy).

3. The domain check (in the core ACL engine)

The resolved numeric permission reaches server-core/lib/acl.js. For domain scope it runs check_domain(), which enforces two things:

  1. Tenant isolation — the acting user's drumate.domain_id must equal the target resource's domain. A user can only run domain-scoped services against their own tenant.
  2. Bitwise privilege — it calls the yp SQL function domain_permission():
-- yellow_page/procedures/domain/permission.sql
SELECT privilege & _perm FROM privilege
WHERE uid = _uid AND domain_id = _dom_id INTO _res;
RETURN IFNULL(_res, 0);

A non-zero result grants the request. A user with no privilege row gets 0 → denied.

Two permission maps — checked vs. stored

server-essentials/lib/lex/ defines several bit maps with different layouts. The domain path crosses two of them:

  • The router resolves ACL src/dest through permission.js (positional single-bit: read = 0b0000010, delete = 0b0001000).
  • Domain privileges are written to yp.privilege.privilege through remit.js (cumulative threshold: dom_admin = 0b0011111, dom_admin_security = 0b0001111, …) — e.g. service/private/organization.js grants with Remit.dom_owner.

domain_permission() then computes stored & asked across the two encodings. The gates happen to land on sensible domain-admin tiers only because the bit positions overlap — not because the maps share a definition. Shifting a bit in either map would silently mis-gate these services (with no error, since the path is fail-closed). Treat the two maps as coupled when editing either.

Scope spellings

check_domain() only enforces the checks when scope is exactly "domain". The "organisation"/"organization" spellings short-circuit to granted, but no live service uses them (all 9 domain-scoped services in server-team use "domain"), so this is currently dead code rather than a reachable bypass.

Notes & gotchas

  • No declared FOREIGN KEY constraints. Referential integrity is enforced in application and stored-procedure code, not by the database. The relationships above are join conventions, not DB-level constraints.
  • JSON is the source of truth for drumate. Its flat columns are read-only VIRTUAL projections of profile — always write to profile.
  • Unix-timestamp integers, not DATETIME, are used for ctime/mtime.

Legacy twins

Some deprecated duplicate tables exist alongside the live ones. Prefer the singular / British-spelled tables:

Live tableLegacy twinDifference
domaindomainsdomains is utf8mb3, name varchar(50), no unique key — unused
organisationorganizationorganization has home instead of link; migration twin
organisation_entityID-remapping scratch table (org_id ↔ old_id/temp_id), not part of the runtime model