diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 6866a7dc39..592e26199c 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -114,6 +114,113 @@ export const opportunityApproval: Flow = { For multi-step approvals, chain multiple `approval` nodes. For parallel approvals, see the aggregating-node pattern in [Flow Metadata](/docs/automation/flows). +## The full lifecycle — submit to field change + +What actually happens between "the flow hits the approval node" and "the record +says approved": + + + +### The request opens, the run pauses + +The node writes a `sys_approval_request` row: `status: 'pending'`, +`pending_approvers` (the resolved approver list), and — if you declared +`approvalStatusField` — it mirrors `'pending'` onto your record's field. The +record is **locked** against edits while pending (`lockRecord`, default `true`), +and the flow run parks until a decision arrives. + +Only `approvers` is required on the node; everything else has a default +(`behavior: 'first_response'`, `lockRecord: true`, `maxRevisions: 3`). + + +**`type: 'role'` is not a position.** In an approver entry, `role` means the +better-auth membership tier (`owner` / `admin` / `member`). Naming a business +role like `sales_manager` there matches nobody and the request silently has no +approvers — use `{ type: 'position', value: 'sales_manager' }`. + + +### The approver finds it in their queue + +Requests land in **Setup → Approvals → Requests**, whose `my_pending` view +filters to `status = pending` and `pending_approvers` containing the current +user. Programmatically: + +```bash +curl -b cookies.txt \ + "https://your-app.example.com/api/v1/approvals/requests?status=pending&approverId=usr_123" +``` + +`approverId` accepts a user id, an email, or `role:` — and takes several +values (comma-separated or repeated) to cover a person's identities in one call. +Other filters: `status`, `object`, `recordId`, `submitterId`, `q`, `limit`, +`offset`. + + +**Opening a request notifies nobody.** There is no built-in "you have an +approval waiting" message today — an approver only discovers it by looking at +the queue. If people need to be told, do one of: add a `notify` node next to +the approval node in the flow (the practical answer), or install a messaging +service and drive `remind()`. Reminder, escalation, return, and reassignment +*do* publish notification topics — the initial request doesn't. + + +### The decision + +```bash +curl -b cookies.txt -X POST \ + https://your-app.example.com/api/v1/approvals/requests/req_456/approve \ + -H "Content-Type: application/json" \ + -d '{ "comment": "Within budget." }' +# POST .../reject for the other direction; body: { actorId?, comment? } +``` + +`actorId` defaults to the caller. The actor **must** be in `pending_approvers` +or the call returns 403 (`FORBIDDEN: actor '…' is not a pending approver`); a +request that isn't pending returns 409 (`INVALID_STATE`). Always go through +these endpoints — never resume the flow run directly. + + +**With `behavior: 'unanimous'`, an approve is not the end.** Every approver but +the last only trims `pending_approvers`; the request stays `pending` +(`finalized: false`) and the flow stays parked. Only the final approval +finalizes it. + + +### The record changes and the flow resumes + +On finalization the request row gets `status: 'approved'` (or `'rejected'`), +`pending_approvers: null`, and `completed_at`; your `approvalStatusField` is +mirrored to the same value, and the record unlocks. The parked run resumes down +the `approve` or `reject` branch. + +Statuses in full: `pending`, `approved`, `rejected`, `recalled`, `returned`. + + + +### Timeouts and escalation + +`escalation` is real, not decorative: set `enabled: true` and `timeoutHours`, +and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or +`auto_reject`. Auto decisions run through the normal decide path, so the flow +resumes exactly as if a human had clicked. Every escalation writes an audit row. + + +**Escalation needs the job service.** The plugin sweeps pending requests on an +internal timer (~5 min). Without a `job` service registered, SLA timers are +**display-only** and no escalation ever fires. + + +### Error codes + +| Code | HTTP | Meaning | +|:---|:---|:---| +| `VALIDATION_FAILED` | 400 | Malformed request | +| `FORBIDDEN` | 403 | Actor isn't a pending approver | +| `REQUEST_NOT_FOUND` | 404 | No such request | +| `DUPLICATE_REQUEST` | 409 | A pending request already exists for this record | +| `INVALID_STATE` | 409 | The request is no longer pending | +| `THROTTLED` | 429 | Rate limited | + ### Best practices ✅ **DO:** diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index d8e5d986cf..d8541eac01 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -31,9 +31,10 @@ const approvalFlow = { type: 'decision', label: 'Check Amount', config: { + // decision expressions use {braces} around variables — see Expressions in flows conditions: [ - { label: 'High Value', expression: 'order_amount > 10000' }, - { label: 'Standard', expression: 'order_amount <= 10000' }, + { label: 'High Value', expression: '{order_amount} > 10000' }, + { label: 'Standard', expression: '{order_amount} <= 10000' }, ], }, }, @@ -41,7 +42,7 @@ const approvalFlow = { id: 'auto_approve', type: 'update_record', label: 'Auto Approve', - config: { object: 'order', fields: { status: 'approved' } }, + config: { objectName: 'order', fields: { status: 'approved' } }, }, { id: 'request_approval', @@ -166,11 +167,11 @@ Each node performs a specific action in the flow. type: 'create_record', label: 'Create Follow-up Task', config: { - object: 'task', + objectName: 'task', fields: { title: 'Follow up on {record.name}', assignee: '{record.owner}', - due_date: 'TODAY() + 7', + due_date: '{TODAY() + 7}', // braces required — without them this writes the literal text }, }, } @@ -546,6 +547,84 @@ The plugin is a **soft dependency** on `metadata` — it tolerates running without `MetadataPlugin` and it logs (not throws) on per-flow registration failures so one broken flow does not abort startup. +## Expressions in flows + +A flow mixes **three expression dialects**, and using the wrong one is the +single most common way a flow silently misbehaves. Which dialect applies is +decided by *where* the expression sits — not by what it looks like: + +| Where | Dialect | Write it like | Bindings | +|:---|:---|:---|:---| +| Start-node `condition` | **CEL** (bare, no braces) | `record.amount > 500` | `record.*`, `previous.*`, bare field names, `vars.*` | +| Edge `condition` | **CEL** (bare, no braces) | `record.status == 'open'` | same as above | +| Decision-node `conditions[].expression` | **Template compare** (braces required) | `{order_amount} > 10000` | flow variables by name, in `{…}` | +| Field values in `create_record` / `update_record` | **Interpolation** (braces required) | `'Follow up on {record.name}'`, `'{TODAY() + 7}'` | `{var}`, `{var.path}`, `{$User.Id}`, `{$User.Email}`, `{NOW()}`, `{TODAY()}`, `{TODAY() + 90}` (whole days) | + + +**The two failure modes to memorize:** + +1. **Braces missing in a decision expression** — `'order_amount > 10000'` isn't + evaluated as a variable at all. It compares the *string* `"order_amount"` + against `"10000"`, which is **always true**, so the flow always takes the + first branch and never tells you. Write `'{order_amount} > 10000'`. +2. **Braces missing in a field value** — `due_date: 'TODAY() + 7'` writes the + literal text `TODAY() + 7` into the field. Write `'{TODAY() + 7}'`. + +The mirror mistake is putting braces *into* a CEL condition +(`'{record.amount} > 500'`) — CEL conditions fail loudly rather than silently, +with an error that tells you to drop the braces. + + +CEL conditions that fail to evaluate raise an error and stop the run — they +never quietly pass. See [Formulas](/docs/data-modeling/formulas) for the CEL +language itself. + + +**A boolean gotcha:** SQLite/libSQL store booleans as `0`/`1`, and CEL's `1 != +true`. Compare against the stored shape (`record.done == 1`) or normalize the +value before the condition. + + +## Run a flow via API + +Flows of any type can be launched over HTTP — this is what an external system, +a scheduled job outside the platform, or the Console's test runner uses: + +```bash +curl -b cookies.txt -X POST \ + https://your-app.example.com/api/v1/automation/order_approval/trigger \ + -H "Content-Type: application/json" \ + -d '{ "recordId": "ord_123", "objectName": "order", "params": { "priority": "high" } }' +# → { "success": true, "data": { ... }, "meta": { ... } } +``` + +| Endpoint | Purpose | +|:---|:---| +| `POST /api/v1/automation/:name/trigger` | Start a flow (canonical) | +| `GET /api/v1/automation/:name/runs` | List runs (`?limit`, `?cursor`) | +| `GET /api/v1/automation/:name/runs/:runId` | One run's detail (404 `Execution not found`) | +| `POST /api/v1/automation/:name/runs/:runId/resume` | Resume a paused run — body `{ inputs, output, branchLabel }` | +| `GET /api/v1/automation/:name/runs/:runId/screen` | The pending screen of a screen-flow run | + +**Passing inputs.** Declare variables with `isInput: true`, then send them in +`params` under the same names — only declared inputs are bound. `recordId` and +`objectName` are lifted into `params` for you (plus an `Id` alias), +and `event` defaults to `'manual'`. + +**Identity.** The caller's identity (user, positions, permissions, tenant) is +forwarded into the run, so a `runAs: 'user'` flow executes under that caller's +row-level security. See [API Authentication](/docs/api#authentication) for +credentials. + + +**`success: true` doesn't always mean "it ran".** If the start condition isn't +met, the response is a *successful* skip: +`{ "success": true, "data": { "skipped": true, "reason": "condition_not_met" } }`. +A self-triggering flow caught by the loop guard reports +`reason: "reentrancy_loop_guard"` the same way. Check `skipped` before assuming +the work happened. + + ## Console Flow Viewer & Test Runner Every flow can surface in the Console metadata browser under `/_console/`. @@ -558,8 +637,9 @@ the flow viewer plugin is installed: | **Run** | `FlowTestRunner` | Auto-generates a form for every `isInput: true` variable (with type-aware coercion for `number` / `boolean` / `object` / `list`), executes the flow against the per-project kernel, and shows the returned outputs + run id | | **Runs** | `FlowRunsPanel` | Lists historical executions for the selected flow with status, duration, and a deep-link to the run record | -The runner posts to the standard automation execute endpoint, scoped to -the active project. All three components participate in the Studio +The runner posts to the same +[`POST /api/v1/automation/:name/trigger`](#run-a-flow-via-api) endpoint, +scoped to the active project. All three components participate in the Studio authentication / project-scope context, so they work identically in single-project mode and in cloud mode. diff --git a/content/docs/permissions/meta.json b/content/docs/permissions/meta.json index 4da7101f22..380e12fca9 100644 --- a/content/docs/permissions/meta.json +++ b/content/docs/permissions/meta.json @@ -12,6 +12,7 @@ "positions", "delegated-administration", "sharing-rules", + "rls", "field-level-security", "attachments-access", "permission-metadata", diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx new file mode 100644 index 0000000000..ea81d575b7 --- /dev/null +++ b/content/docs/permissions/rls.mdx @@ -0,0 +1,188 @@ +--- +title: Row-Level Security (RLS) +description: Declarative per-row policies compiled into query filters — the grammar, the context variables, how policies compose, and the fail-closed contract. +--- + +# Row-Level Security (RLS) + +RLS answers a question the other layers can't: *which rows* of an object may +this user touch? Object permissions say "can read tasks"; the OWD baseline says +"tasks are private"; RLS says **"only the tasks assigned to you"** — as a +declarative policy, compiled into the query. + +Two properties define how it behaves: + +- **It's a filter, not a row hook.** Policies compile to a data filter that is + pushed down into the query, so a restricted user's list is *narrower* — they + don't get denied, they get fewer rows. +- **It's fail-closed.** A policy that can't compile denies everything rather + than admitting anything. See [the contract](#the-fail-closed-contract). + + +RLS is the expert escape hatch. Reach for it when +[sharing rules](/docs/permissions/sharing-rules), the +[OWD baseline](/docs/permissions/permissions-matrix), and +[scope depth](/docs/permissions/positions) can't express the rule — not before. + + +## Anatomy of a policy + +Policies live on a permission set under `rowLevelSecurity`: + +```typescript +import { definePermissionSet } from '@objectstack/spec'; + +export const ContributorAccess = definePermissionSet({ + name: 'contributor', + label: 'Contributor', + objects: { + showcase_task: { allowRead: true, allowEdit: true }, + }, + rowLevelSecurity: [ + { + name: 'task_own_rows', + label: 'Own Tasks Only', + object: 'showcase_task', + operation: 'select', + using: 'assignee == current_user.email', + positions: ['contributor'], + enabled: true, + priority: 10, + }, + ], +}); +``` + +| Property | Type | Notes | +|:---|:---|:---| +| `name` | `string` | snake_case identifier | +| `label` | `string` | Human-readable name | +| `object` | `string` | Target object — or `'*'` to apply to every object | +| `operation` | `'select' \| 'insert' \| 'update' \| 'delete' \| 'all'` | Which operation the policy guards | +| `using` | `string` | Predicate for rows the user may **see / act on** (compiled into the query filter) | +| `check` | `string` | Predicate rows must satisfy **after a write**. Omit it and `using` is reused | +| `positions` | `string[]` | Which positions the policy applies to. Omit = everyone | +| `enabled` | `boolean` | Default `true` | +| `priority` | `number` | Default `0` | + +At least one of `using` / `check` is required. + +**Choosing `operation`:** `select` narrows reads (the common case); +`insert` / `update` / `delete` guard the matching write; `all` applies to +everything. Internally `find` / `findOne` / `count` / `aggregate` all map to +`select`. + +## The expression grammar + +This is the part that bites. RLS expressions are **not** general CEL — only +four forms compile: + +| Form | Example | +|:---|:---| +| Column equals a context property | `created_by == current_user.id` | +| Column equals a literal | `status == 'active'` | +| Column is in a context array | `owner_id IN (current_user.org_user_ids)` | +| Always true | `1 = 1` | + +Everything else is rejected, including `AND` / `OR` / `NOT`, comparison +operators other than equality (`>`, `<`, `!=`), `IS NULL`, `NOT IN`, +`LIKE` / `ILIKE`, regex, `ANY` / `ALL`, subqueries, and time functions +(`NOW()`, `CURRENT_DATE`). + + +**There is no `AND` / `OR` inside a policy — use multiple policies instead.** +Policies on the same object compose with **OR** (see below), which covers +"owner *or* team member". For "owner *and* active", model the second condition +in the data (a dedicated column or a narrower context array) rather than +reaching for a compound expression the compiler will reject. + + +## Context variables + +Predicates reference the acting user through `current_user`, and record fields +as **bare column names** (no `record.` prefix — that's the UI/flow convention, +not this one): + +| Variable | Source | +|:---|:---| +| `current_user.id` | The acting user's id | +| `current_user.organization_id` | The acting tenant | +| `current_user.positions` | The user's positions (array) | +| `current_user.org_user_ids` | User ids in the user's org (array) | +| `current_user.email` | The user's email | + + +**`current_user.name` is deliberately not exposed.** Names aren't unique — a +policy keyed on a display name would hand a second "John Smith" someone else's +rows. Match on `id` or `email`, which are. + + +Deployments can inject additional membership sets (team ids, territory codes) +into the evaluation context, which lets policies express set membership without +subqueries the grammar doesn't allow. + +## How policies compose + +``` +Layer 0 (tenant isolation) ─┐ + ├── AND ──► the query's final filter +Layer 1 (business RLS) ─┘ + policy A OR policy B OR … +``` + +- **Tenant isolation is a separate layer that always applies first** and is + never OR-ed away by a business policy. +- **Multiple policies on the same object are OR-ed** — a row is admitted if it + matches *any* applicable policy. This is the intended way to express + alternatives. +- The full evaluation pipeline around RLS is: object CRUD → FLS → OWD baseline → + depth → sharing → RLS. RLS narrows what the earlier layers already allowed; + it never widens. +- A superuser bypasses business policies but **not** tenant isolation — + crossing tenants requires a genuine platform admin. +- Children of a master-detail parent with `sharingModel: 'controlled_by_parent'` + derive access from the master record instead. + +## The fail-closed contract + +Four ways a policy denies rather than leaks: + +1. A policy exists but **every** applicable expression fails to compile → a + deny-everything filter. +2. A referenced context variable is missing, null, or an empty array → that + policy drops out (it cannot match). +3. A policy references a column the object doesn't have → deny. +4. `check` is omitted → `using` is reused for writes, never "anything goes". + + +**The one non-obvious case: no applicable policy means *no restriction*, not +"deny".** RLS only narrows what other layers granted — it is not what stops an +un-permissioned user. If a user sees rows you expected RLS to hide, check +whether any policy actually applies to their positions and the operation. + + +## Verify it + +Don't guess — ask the runtime: + +```bash +curl -b cookies.txt \ + "https://your-app.example.com/api/v1/security/explain?object=showcase_task&operation=select&userId=usr_123" +``` + +The response walks the layers (`tenant_isolation`, `object_crud`, `fls`, +`owd_baseline`, `depth`, `sharing`, `rls`) with a `verdict` per layer — +`grants`, `denies`, `narrows`, `widens`, `neutral`, or `not_applicable` — and +tags each with `kernelTier` (`layer_0_tenant` vs `layer_1_business`). An RLS +policy that fired shows up as `narrows` on the `rls` layer; per-record +explanations mark rows `admitted` / `excluded`. + +Explaining another user's access requires `manage_users` or delegated-admin +rights. Pair it with Impersonate in the Console to see the same rows they'd see. + +## Related + +- [Authorization Model](/docs/permissions/authorization) — how all the layers fit together +- [Sharing Rules](/docs/permissions/sharing-rules) — the declarative layer to try first +- [Explain](/docs/permissions/explain) — the verdict vocabulary in full +- [Access Recipes](/docs/permissions/access-recipes) — requirement → layer mapping diff --git a/content/docs/ui/meta.json b/content/docs/ui/meta.json index 2385275725..f768aa09e4 100644 --- a/content/docs/ui/meta.json +++ b/content/docs/ui/meta.json @@ -8,6 +8,7 @@ "views", "actions", "dashboards", + "translations", "forms", "doc-pages", "setup-app", diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx new file mode 100644 index 0000000000..9d0d31c13c --- /dev/null +++ b/content/docs/ui/translations.mdx @@ -0,0 +1,144 @@ +--- +title: Translations +description: Labels and UI text as metadata — one bundle per locale, resolved per request, with CLI tooling to draft and to gate coverage in CI. +--- + +# Translations + +Every label in an ObjectStack app — object and field names, picklist options, +view titles, action buttons, app navigation — is metadata, not a string baked +into a component. Adding a language means adding a **bundle**, not touching the +app. + + +**Single-locale apps need none of this.** `translations` and `i18n` are both +optional; with no bundle the runtime falls back to the labels declared on your +metadata. Built-in system fields (`owner_id`, `created_at`, …) carry their own +labels either way. + + +## Your first bundle + +A bundle is a **map of locale → translations**, registered on your stack: + +```typescript title="src/translations/crm.translation.ts" +import { defineTranslationBundle } from '@objectstack/spec'; + +export const CrmTranslationBundle = defineTranslationBundle({ + en: { + objects: { + crm_account: { + label: 'Account', + pluralLabel: 'Accounts', + fields: { + name: { label: 'Account Name' }, + industry: { label: 'Industry' }, + }, + }, + }, + }, + 'zh-CN': { + objects: { + crm_account: { + label: '客户', + pluralLabel: '客户', + fields: { + name: { label: '客户名称' }, + industry: { label: '行业' }, + }, + }, + }, + }, +}); +``` + +```typescript title="objectstack.config.ts" +export default defineStack({ + // ... + translations: [CrmTranslationBundle], + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, +}); +``` + +## What you can translate + +| Surface | Where it lives in the bundle | +|:---|:---| +| Object label / plural / description | `objects..label` / `pluralLabel` / `description` | +| Field labels, help text, placeholders | `objects..fields..label` / `help` / `placeholder` | +| Picklist option labels | `objects..fields..options.` | +| View titles, descriptions, empty states | `objects.._views.` | +| Action labels, confirm text, success messages | `objects.._actions.` | +| Form sections | `objects.._sections.
` | +| App navigation | `apps..navigation..label` | +| Dashboards and widgets | `dashboards.` | +| Global actions, settings, messages | `globalActions`, `settings`, `messages` | + +The metadata types resolved per request are **object, field, view, action, app, +and dashboard**. + +## How a locale is chosen + +Per request, in this order: + +1. The `Accept-Language` header (the client SDK sends it; `setLocale()` sets it) +2. A `?locale=` query parameter +3. The stack's `defaultLocale` + +Within the bundle, matching walks: exact (`zh-CN`) → case-insensitive → +**base language** (`zh-CN` → `zh`) → variant expansion (`zh` → `zh-CN`). If +nothing matches, the chain falls back to `en`, and finally to the literal label +on the metadata. **Translation lookup never throws** — a missing string +degrades to the next best text. + +Resolved labels are served straight from the REST metadata endpoints (the +locale is part of the ETag), so the Console and any SDUI client get translated +metadata without doing lookups themselves. + +## Organizing the files + +`i18n.fileOrganization` picks the layout: + +- **`per_locale`** (default) — one file per language, combined in the bundle. + This is what the Todo example does (`en`, `zh-CN`, `ja-JP`). +- **`bundled`** — every locale in one file, like the CRM example above. Fine + for two locales; unwieldy past that. +- **`per_namespace`** — split by module. + +## Draft with the CLI, gate in CI + +```bash +# Scaffold entries for everything translatable that isn't yet +npx os i18n extract --locales zh-CN --fill todo --out src/translations + +# Report coverage; fail the build when it slips +npx os i18n check --strict --threshold 95 +``` + +`os i18n check` exits non-zero on violations, so it works as a CI gate. +A missing string in the **default** locale is an error; missing strings in +other locales are warnings until you set `--strict` / `--threshold`. The Todo +example ships a completeness test alongside its bundles — worth copying. + +## Current boundaries + +Honest limits worth knowing before you plan around them: + +- **`validationMessages` has no runtime consumer today.** The schema accepts it + and the coverage tooling counts it, but nothing reads it back — validation + errors are not translated through bundles yet. +- **`messageFormat: 'icu'` is declared but not implemented** — plural/gender + formatting isn't available; `simple` is what runs. +- **Two bundle shapes exist.** File-authored bundles use the `objects.` + shape documented here, which is what the resolver reads. The `translation` + metadata type authored at runtime uses an object-first (`o.`) shape, + and the two are not bridged — author translations **as files** for now. +- AI-suggested translation fields (`aiSuggested`, `aiConfidence`) are schema + only. + +## Related + +- [Views](/docs/ui/views) · [Apps](/docs/ui/apps) · [Actions](/docs/ui/actions) — the surfaces you're translating +- [Objects](/docs/data-modeling/objects) — where the fallback labels are declared +- [i18n standard](/docs/protocol/kernel/i18n-standard) — the normative protocol +- [Translation schema reference](/docs/references/system/translation) — every field, generated from the spec