Home  ›  tafalo5 — End-user guide
v1 · 2026-05

End-user guide for tafalo5

tafalo5 (codename tfl5) is a multi-app, schema-driven data platform: you declare your data shape (resource), create records (docs), bind your own domain, and share with fine-grained control. This guide is for app owners, data designers, editors, regular users, and anonymous visitors.

2. Model & roles

Everything in tafalo5 revolves around four objects and a single permission check. Understanding the diagram below covers ~90% of platform usage.

platform (system-wide, singleton)
 ├── user        (account; shared across all apps)
 ├── group       (global group, peer of app)
 └── app         (your workspace — literally called "app")
      ├── role         (in-app role)
      ├── resource     (data type / schema)
      │    └── doc     (a concrete record)
      └── folder/file  (binary store)

2.1 Roles you may encounter

CodeRoleMeaning
AnonAnonymous visitorNot signed in. Reads only what's public.
UserUserSigned in; automatically a member of G_author.
AppMgrApp managerListed in app.managers. Can change the app's own ACL.
AppDesApp designerCreates & edits resources and roles.
AppDevApp developerCreates root files/folders inside the app.
ResMgr / ResDesResource manager / designerEdits the ACL or schema of a specific resource.
AuthorDoc authorThe user who created a doc. Immutable after creation.
Editor / ReaderEditor / ReaderListed in doc.editors or doc.readers.
GrpMgrGroup managerEdits a group's members or ACL.
OperatorPlatform operatorHas CLI access to the server & KMS. Not a runtime-API role.
Note. Roles in tafalo5 aren't titles — they are membership in an ACL list. You can be AppMgr on app A and only a Reader on app B at the same time.

3. Quick start — 5 steps

  1. Register an account (username, email, password ≥ 6 chars).
  2. Sign in — the server issues a 24-hour session cookie.
  3. Create an app — you become its sole app.managers entry.
  4. Bind a domain (optional) — add A + TXT records to your DNS.
  5. Create your first resource and doc — your data is online.
Tip. Every workflow below can be done through the admin UI at https://<domain>/cpanel or via the JSON API (see §28 SDK).

4. App

An app is an independent workspace with its own schema, data, users, and any number of bound domains.

  • Each app has its own app_key minted by KMS — sensitive fields are encrypted with it.
  • The creator becomes app.author and the only entry in app.managers.
  • Your maximum number of apps is bounded by license.user_max_apps (see §23).
  • Ownership can be transferred to another user.

5. Resource

A resource is a schema — you declare fields, validators, and per-field sensitivity (public / private / secret). When a user creates a doc, the platform:

  • Validates data against each field's rules;
  • Runs the before_create DSL/hooks;
  • Generates doc.tid and stamps doc.author;
  • "Freezes" the doc's ACL from the resource.acls template;
  • Encrypts every field whose sensitivity != "public" with the app_key.

6. Doc

A doc is one concrete record of a resource. After creation its ACL is immutable — nobody can change it directly. Only app.managers can re-apply the template in bulk via POST /data/reset/<resource_id>.

FieldMeaning
authorUUID of the creator, immutable.
editorsWho may edit doc.data.
readersWho may read. [] = public.
deletableWho may delete the doc.
noaccessAbsolute deny list.
historiesAppend-only: [ts, user, action].

7. Files & folders

Files and folders are each app's binary store. A folder can carry a folder.acls template that is stamped onto child files/subfolders at creation time. Files live on S3 under <app_tid>/files/<tid>/<tid>.bin; the original filename is encrypted before it hits the database.

8. Domain

A domain is bound to an app after DNS verification succeeds. An app can host many domains, and each can have its own theme, language, noaccess list, and URL routes.

9. Groups & roles

A group is global (peer of an app). A role exists inside one app. Both can appear in any ACL list.

  • G_author = every signed-in user (implicit).
  • anonymous = every visitor (implicit).
  • G_<uuid> = a global group you created.
  • [<uuid>] = an in-app role — note the square brackets.

10. Workflow — Register & sign in

10.1 Register

Endpoint: POST /reg with body {username, password, re_password, email}.

  • Username must be unique; email must be unique.
  • Password must be ≥ 6 characters and match re_password.
  • The email is encrypted with the master key before storage; only a hash is kept for lookup.
  • Per-IP rate limiting via the TFL5_RATE_LIMIT_REG env var.

10.2 Sign in

Endpoint: POST /login with {username, password}. The server issues an _token cookie:

HttpOnly Secure SameSite=Lax
  • Value is AEAD ciphertext (ChaCha20-Poly1305) — not readable client-side.
  • Lifetime 24h, with rolling refresh on every authenticated request.
Security note. Wrong username or wrong password returns the same message ("Invalid account or password") — this is intentional to prevent user enumeration, not a bug.

10.3 Change password

Self-reset: POST /user/resetp {old_password, new_password}. Wrong old password → 400. After the change, existing sessions can be invalidated if the operator has enabled token-version bumping.

10.4 Sign out

POST /logout — the server sets the cookie to empty with Max-Age=0. Then POST /user returns {isSignout: true}.

10.5 Delete account

Self-delete succeeds only when user.app_count == 0. If you still own apps, you must delete or transfer them first.

11. Workflow — Create an app

  1. Prerequisite: Your account is in platform.designers (default is G_author, so every signed-in user can create apps).
  2. Send POST /app/update with {data: {name, description, ...}}.
  3. The server generates app.tid, sets app.author = you, adds you to app.managers.
  4. KMS mints an app_key, stored in the app_keys table.
  5. user.app_count is incremented by 1.
ErrorCauseHow to resolve
400 "Quota exceeded"Exceeded user_max_appsDelete an old app or upgrade your license.
403Listed in platform.noaccessContact your operator.

12. Workflow — Bind a custom domain

Convention: verify-then-save. A domain row is only persisted with active=true after DNS proof passes.

  1. Preview: POST /app/domain/preview {app_tid, domain} — the server returns the A-record target and a verify token.
  2. Configure DNS:
    • A: <domain>platform.domain_a_record_target.
    • TXT: _tfl5.<domain> → the verify_token value.
  3. Verify & save: POST /app/domain/add {app_tid, domain, verify_token}. Both DNS records correct → a domains row is inserted with active=true.
  4. TLS: Caddy on-demand mints the certificate on the first request to that domain.
  5. Weekly re-verify: The platform re-checks DNS periodically. Four consecutive failures (~30 days) flip active=false and notify you.
Per-domain configuration (theme, language, noaccess, routing)

POST /app/domain/update/<domain_tid> {theme, lang, single_page, url_routes, noaccess} lets one app behave differently per domain. For example, intranet.acme.com can set noaccess: ["anonymous"] to block anonymous visitors while acme.com stays publicly readable.

13. Workflow — Design a resource

Only AppDes (members of app.designers) can create resources.

POST /app/resource/add
{
  "data": {
    "ma": "post",            // unique code within the app
    "name": "Blog post",
    "fields": [
      { "field": "title",   "type": "text",  "sensitivity": "public",  "validator": ["required","minlen:3"] },
      { "field": "body",    "type": "html",  "sensitivity": "public" },
      { "field": "secret",  "type": "text",  "sensitivity": "private" }
    ],
    "acls": {
      "editors":   ["@author"],
      "readers":   [],            // [] = public
      "deletable": ["@author"]
    },
    "sharing": true
  }
}
Sensitivity matters. public fields live plaintext in data_indexed and are filterable. private/secret fields are encrypted in data_secretnot filterable; filtering on them returns 400.

14. Workflow — Doc CRUD

14.1 Create a doc

POST /data/add/<resource_id>
{ "data": { "title": "Hello world", "body": "..." } }

14.2 List & filter

POST /data/gets/<resource_id>
{
  "filter_rules": [
    { "field": "title", "op": "contains", "value": "hello" }
  ],
  "skip": 0,
  "limit": 20         // capped at 100
}

The query layer automatically folds ACL conditions into the SQL — you only receive docs you're allowed to read. Docs reached through a share are field-projected by share.fields.

14.3 Update & delete

  • POST /data/edit/<resource_id>/<doc_id> — partial merge of data, appends to histories.
  • POST /data/del/<resource_id>/<doc_id> — soft delete (sets deleted_at).

14.4 Bulk ACL reset

AppMgr only: POST /data/reset/<resource_id> {filter_rules}. The server recomputes editors/readers/deletable/noaccess for each matching doc from the current resource.acls template, preserving author.

15. Workflow — Upload & download files

  1. Init: POST /app/file/upload-init {filename, size} → returns {file_tid, upload_url} (presigned PUT, 10-minute TTL).
  2. PUT to S3 directly from the client (bypasses the server).
  3. Finalize: POST /app/file/finalize {file_tid} — the server HEADs the object, verifies size, writes the row, charges quota.
  4. Download: GET /file/<file_tid> → 302 redirect to a signed S3 URL (5-minute TTL).
SituationBehavior
Exceeds app_max_storage or user_max_total_storage400 "Storage limit reached".
Declared size ≠ S3 object sizeReject and delete the S3 object.
License on_quota_exceed = read_onlyBlocks every POST CRUD (advanced).

16. Workflow — Sharing

Sharing is read-only at the doc level. You cannot share folders, files, or whole resources. Editing and deletion still go through the formal ACL.

16.1 Mint for a specific user

POST /share
{ "app_id": "...", "resource_id": "post", "doc_id": "...",
  "target": "u_bob", "fields": null }

16.2 Mint a public link

POST /share
{ ..., "target": "anonymous", "generate_token": true,
  "expires_at": 1893456000000 }
=> { "share": {...}, "token": "<plaintext_one_time>",
     "url": "https://acme.com/?_share=<plaintext>" }
The token is shown only once. Save it immediately; the server keeps only a hash.

16.3 Field filter

fields: ["title","summary"] → the recipient only sees those two fields. Other fields are stripped; metadata (tid, author, created_at...) is always shown. ACL fields are always hidden from share-based access.

16.4 Revoke

DELETE /share/<share_tid> — sets revoked_at. The row is kept for audit. Automatic cleanup after 12 months.

16.5 Interaction with other rules

  • noaccess beats share. An app with noaccess: ["anonymous"] denies even a valid public token.
  • resource.sharing = false is a kill switch: new shares get 400; existing shares are temporarily ignored (and reactivated if the flag is flipped back).
  • Soft-deleted doc → all of its shares stop working (every check injects deleted_at = 0).

17. Workflow — Multiple domains, one app

  • Bind both acme.com and acme.vn to the same app — shared data, different theme/language.
  • intranet.acme.com with noaccess: ["anonymous"] — public is locked out.
  • Per-domain url_routes: "/posts/:slug" → server-side render with the doc preloaded.

18. Access control — the permission set

On every request the server builds a permission set:

permission_set = [
  user.tid,                                            // bare uuid
  ...user.groups.map(g => g.tid),                      // "G_<uuid>"
  ...user.roles_in_current_app.map(r => "[" + r.tid + "]"),
  is_authenticated ? "G_author" : "anonymous"
]
uuid Specific user G_xxx Global group [xxx] In-app role G_author Signed in anonymous Visitor

A request "passes" a gate when permission_set ∩ gate_field ≠ ∅ — i.e., they share at least one element.

19. ACL fields

FieldGates this opNotes
managersEdit the entity's ACL (and license_tid on apps).Absent on docs (docs are immutable).
designersCreate/edit child schemas (resources, roles).On platform: also "create app + create group".
developersCreate root files/folders inside an app.App tier only.
authorsCreate docs of a resource.Resource tier only.
editorsEdit content / metadata.
readersRead content. readers: [] means public.Important.
deletableDelete the entity (also requires noaccess clearance).
noaccessAbsolute deny, cascading to children.Always wins over any allow.

20. noaccess cascade

platform.noaccess  → denies: app, group, user (everything below)
app.noaccess       → denies: resource, doc, file, folder, role
resource.noaccess  → denies: docs of that resource
folder.noaccess    → denies: file + subfolder inside (recursive)
doc/file/role/group/license.noaccess → leaf (the entity itself only)
deny_set when checking entity E:
  deny_set = platform.noaccess
          ∪ A.noaccess              (E belongs to app A)
          ∪ R.noaccess              (E belongs to resource R)
          ∪ F.ancestors.noaccess    (E is a file/folder, recursive)
          ∪ E.noaccess

permission_set ∩ deny_set ≠ ∅  →  DENY immediately, no further checks.

21. Templates & @author

Three places carry templates: resource.acls, folder.acls, app.acls. When a child entity is created, the template is applied overwrite-style (not union) to the child's ACL fields, and the pseudo-token @author is replaced with the creator's UUID.

resource.acls.editors = ["@author", "[t_editor_role]"]

→ alice (u_alice) creates doc d1:
   d1.author  = "u_alice"
   d1.editors = ["u_alice", "[t_editor_role]"]

During reset, the server re-substitutes @author using the stored doc.author — the author never changes.

22. Who can change ACLs?

ActionAuthorized parties
Edit platform.ACLplatform.managers (empty by default → CLI operator only).
Edit licenselicense.managers.
Edit app.ACL (incl. app.acls, license_tid)app.managers.
Edit resource.ACL (incl. acls, sharing)resource.managers.
Edit folder/file/group/role.ACLThe matching X.managers.
Edit doc.ACL directlyNobody — immutable.
Bulk-reset doc.ACLapp.managers only, via POST /data/reset.

23. Platform rules — Quotas & licenses

A license is a "plan" attached to a user or to an app and defines hard ceilings.

23.1 Quota fields

FieldApplies toMeaning
user_max_appsUserMaximum apps the user can own.
user_max_total_storageUserTotal file storage across all of the user's apps.
app_max_storageAppMaximum storage for a single app.
featuresBothcustom_domain, anonymous_share, ...
on_quota_exceedLicenseblock_upload (default) · read_only · suspend_domain.

23.2 Enforcement algorithm

can_create_app(user):
  permission_set ∩ platform.designers ≠ ∅
  AND user.app_count < (user.override_max_apps ?? license.user_max_apps)

can_upload_file(user, app, size):
  not (user in app.noaccess cascade)
  AND permission_set ∩ app.developers ≠ ∅
  AND user.used_storage + size ≤ (override ?? license.user_max_total_storage)
  AND app.used_storage  + size ≤ app_license.app_max_storage

23.3 Over-quota behaviors

  • block_upload — new uploads are rejected; reads still work.
  • read_only — every POST CRUD is rejected.
  • suspend_domain — the domain returns 503 (v2).

23.4 Upgrades & overrides

Operator-only (CLI). Examples:

tfl5 user license <user_tid> pro
tfl5 user override <tid> --max-apps 100

24. Platform rules — Security & encryption

24.1 Encryption at rest

  • Doc fields marked private/secretChaCha20-Poly1305 AEAD ciphertext in data_secret (BYTEA).
  • User email → encrypted with the master key; only a hash is kept for lookup.
  • Original filenames → encrypted into files.original_filename_encrypted; S3 sees only UUIDs.
  • ACL fields store UUIDs, not usernames or emails — a database dump does not leak PII.

24.2 Session cookie

  • AEAD ciphertext keyed by platform_passphrase (server-side).
  • HttpOnly Secure SameSite=Lax
  • Random nonce per encrypt → two consecutive logins produce two different cookies.
  • 24-hour rolling lifetime; optional IP-binding (config) to invalidate cookies stolen from another IP.

24.3 Cross-tenant isolation

Each app has its own app_key; the AAD in the AEAD is bound to app_tid. An operator with app A's key trying to decrypt an app B doc → AEAD AAD failure → decryption error.

24.4 Key rotation

tfl5 keys rotate-app <app_tid>
  → KMS mints version 2
  → background job re-encrypts each doc
  → 30-day grace period before v1 is destroyed

24.5 Audit log tamper detection

Every audit row is hash-chained to the previous one. tfl5 audit verify-chain --last 30d spots tampering by detecting a broken hash.

KMS down = fail-fast. When KMS can't return a key, the server refuses to read/write docs with secret fields and surfaces a clear "encryption unavailable" error. This is by design, not a bug.

25. Platform rules — Usage rules

  • TLS required. Plain HTTP is redirected to HTTPS.
  • Rate limiting on /reg and /share/claim, per IP.
  • Consistent response format: success {result:true, ..., timestamp}; error {result:false, msg:<string|array>}.
  • No filtering on sensitive fields. Filter rules must target fields with sensitivity: "public"; violations return 400.
  • No mock data in dev. Every test goes against real data so that real constraints are exercised.
  • UI strings. The platform default is English. Tenants can override via the /cpanel/lang bundle.

26. Platform rules — Data lifecycle

EntitySoft deleteHard delete / cleanup
Docdeleted_at = nowCleaned up when its resource is hard-deleted.
Resourcedeleted_at = nowDocs of that resource are locked for CRUD immediately.
AppCascade-deletes resources/docs/files/roles; KMS key has a 30-day grace period.
UserBlocked while they still own apps.app_count = 0 → ban=-1 or hard delete.
Sharerevoked_at = nowWeekly cron deletes/archives rows older than 12 months.
Domainactive = falseAfter 4 consecutive DNS verify failures (~30 days).

27. Hooks & validators

Designers wire automated behavior into each resource:

27.1 Field validators

fields: [
  { "field": "title", "validator": ["required", "minlen:3"] }
]

27.2 DSL rules

before_create.set: {
  "data.slug": "lower(replace(data.title, ' ', '-'))"
}

before_delete: [
  { "when": "doc.data.status == 'published'",
    "deny": "Cannot delete a published post" }
]

27.3 Webhooks

after_create.call_webhook:  "notify_slack"
before_create.call_webhook: { name: "validate_invoice", on_fail: "abort" }
  • Hooks are pushed onto a NATS queue; a worker delivers each one with an HMAC signature.
  • on_fail: "abort" + the webhook returning {result:false} aborts the create.
  • Up to 3 exponential-backoff retries.

28. SDK & integration

Three distribution paths:

FormUse caseOutput
GET /sdk.jsSPA tenants — one-line script includeUMD bundle, registers window.TFL5.
npm @tfl5/sdkBuild pipelines (Vite/Webpack/Next)ESM + CJS.
npm @tfl5/cliCodegen TypeScript types from resource schemasCLI binary.

Browser

<script src="/sdk.js"></script>
<script>
  const tfl5 = new TFL5();
  await tfl5.login(username, password);
  const posts = await tfl5.resource("post").list({ filter_rules: [], limit: 20 });
</script>

Node (server-to-server)

import { TFL5 } from "@tfl5/sdk";
const tfl5 = new TFL5({ host: "https://acme.com", appId: "a_xxx",
                        token: process.env.TFL5_TOKEN });
await tfl5.user();

Anonymous share claim

const tfl5 = new TFL5();
await tfl5.claimFromUrl();  // reads ?_share=... + calls /share/claim
const doc = await tfl5.resource("post").get(docId);  // auto-attaches X-Share-Token

29. Common errors

MessageCauseAction
Invalid account or passwordWrong username, wrong password, or banned userDouble-check; contact your operator if it persists.
Email already in useEmail hash collisionUse a different email or sign in to the existing account.
Quota exceededApp slot or storage limit hitFree space or upgrade the license.
Access denied / BannedHit by a noaccess cascade or missing the required roleAsk an AppMgr to grant access or remove the noaccess entry.
Sharing disabledresource.sharing = falseAsk a ResMgr to re-enable it.
Invalid config this domainDomain not verified, or active = falseRe-run the bind-domain workflow.
Field not filterableFiltering on a non-public fieldSwitch to a public field or drop the filter.
Storage limit reachedExceeded app_max_storage or user_max_total_storageDelete old files or upgrade.
DNS A record mismatch / TXT verification failedDNS hasn't propagated or the value is wrongRe-check DNS, wait for TTL, retry preview + add.
encryption unavailableKMS is downWait for the operator to recover — don't hammer retries.

30. FAQ

I just created a doc, but another user still can't read it even after I added them to doc.readers — why?

A doc's ACL is frozen from resource.acls at creation time. Editing resource.acls afterwards doesn't retroactively change existing docs, and editing doc.readers directly is not allowed. The right move: ask an AppMgr to run POST /data/reset/<resource_id> with a filter so the new template is re-applied to existing docs.

I shared a public doc, but anonymous users still get denied.

Most likely the app or domain has noaccess: ["anonymous"]. noaccess always wins over share. Either remove anonymous from the cascading noaccess list, or change strategy and share with signed-in users only.

Why can't I filter by the email field?

Sensitive fields (private/secret) live encrypted in data_secret and have no index → no filtering. The designer would need to mark the field sensitivity: "public" and migrate (re-encrypt) existing data via the CLI.

My session cookie suddenly expired even though I'm active.

Rolling refresh only happens when you send a request with the cookie. If a SPA stays in cached views for > 24h without hitting the API, the cookie expires. Reload the page → sign in again.

Can I delete my own app?

Yes, if you're in app.managersapp.deletable and the app isn't a reserved "native" one. After deletion the resources/docs/files/shares cascade out, and the KMS key enters a 30-day grace period before final destruction.

Does a public share token persist after I close the tab?

The server stores only a hash. The plaintext is returned exactly once when minted. Lost it? Mint a new token and revoke the old one.

What's the rule on backups and data export?

Backups are operator-managed at the server layer (Postgres dumps + S3). Dumps don't self-decrypt — KMS access is required. Self-service export from inside an app: use POST /data/gets + GET /file/<tid> via the SDK. Bulk export through the CLI is a v2 feature.

31. Glossary

ACL — Access Control List. A token list that decides who can do what to an entity.

AEAD — Authenticated Encryption with Associated Data. tafalo5 uses ChaCha20-Poly1305.

App — An independent workspace with its own schema, data, and users.

App key — A KMS-managed encryption key unique to one app.

app_tid / doc.tid — The unique identifier (UUID) of the corresponding entity.

Caddy — Reverse proxy with auto-TLS; asks tfl5 via /internal/caddy/ask before minting a cert.

Cascade — A parent-tier noaccess propagating down to every child entity.

Cell — A deployment unit (its own Postgres + storage). v1 ships with the single cell default.

data_indexed / data_secret — Twin columns holding plaintext public fields (JSON) and encrypted sensitive fields (BYTEA).

Doc — A concrete record of one resource. ACL is immutable after creation.

DSL hook — A declarative rule (set/when/deny) that runs around doc CRUD.

G_author — The implicit group containing every signed-in user.

KMS — Key Management Service; issues app_keys and handles rotation / soft-delete.

License — A plan attached to a user or app that determines quotas and features.

NATS — Message queue used for audit, webhooks, and search indexing.

Permission set — The list of tokens a caller carries within one request.

Platform — The system-wide singleton config row.

Resource — Schema + ACL template + hooks for one kind of doc.

Role — A per-app role (its token is wrapped in square brackets).

Sensitivity — A field's classification: public / private / secret.

Share — A record granting read access to one doc for one target (user / group / role / anonymous).

Soft delete — Setting deleted_at > 0; the default filter hides such entities.

Token (ACL) — One identifier string: <uuid>, G_<uuid>, [<uuid>], G_author, or anonymous.

verify-then-save — The domain-binding principle: insert a row only after DNS proof passes.

↑ Top