Feat v0.7.4 docs surface work (#58)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 39s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m51s
CI/CD / test-sqlite (push) Successful in 2m51s
CI/CD / build-and-deploy (push) Successful in 39s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #58.
This commit is contained in:
@@ -271,12 +271,13 @@ graph TD
|
||||
## Frontend
|
||||
|
||||
Preact (3KB) + htm (tagged template literals). No build step, no bundler
|
||||
(except CM6 via esbuild). IIFE/global-namespace pattern with
|
||||
`sb.register()`/`sb.ns()`.
|
||||
(except CM6 via esbuild). ES modules loaded via `<script type="module">`.
|
||||
The SDK is exposed at `window.sw` — see the [Frontend JS Guide](FRONTEND-JS-GUIDE).
|
||||
|
||||
The shell loads surfaces into a viewport. Extensions use `window.html`
|
||||
and `window.preact` directly. Hooks via `window.hooks`. Vendor libs
|
||||
(marked.js, DOMPurify, KaTeX, CodeMirror 6) baked into the image.
|
||||
The shell provides a two-slot topbar (left title + center slot) that every
|
||||
surface inherits. Extensions use `window.html` and `window.preact` directly.
|
||||
Hooks via `window.hooks`. Vendor libs (marked.js, DOMPurify, KaTeX,
|
||||
CodeMirror 6) baked into the image.
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -41,13 +41,13 @@ services:
|
||||
STORAGE_BACKEND: pvc
|
||||
STORAGE_PATH: /data/storage
|
||||
volumes:
|
||||
- sb_storage:/data/storage
|
||||
- armature_storage:/data/storage
|
||||
depends_on:
|
||||
- postgres
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
sb_storage:
|
||||
armature_storage:
|
||||
```
|
||||
|
||||
## Kubernetes
|
||||
@@ -70,6 +70,7 @@ See the `k8s/` directory for example manifests. Key considerations:
|
||||
| `JWT_SECRET` | `dev-secret-change-me` | Token signing key -- **must change** |
|
||||
| `ENCRYPTION_KEY` | | AES-256 key for credential vault |
|
||||
| `AUTH_MODE` | `builtin` | `builtin`, `mtls`, `oidc` |
|
||||
| `TLS_MODE` | (empty) | `native` for node-to-node mTLS. Requires `MTLS_CERT_PATH` and `MTLS_KEY_PATH`. |
|
||||
| `STORAGE_BACKEND` | auto | `pvc` or `s3` |
|
||||
| `STORAGE_PATH` | `/data/storage` | PVC mount point |
|
||||
| `BASE_PATH` | | URL prefix (e.g., `/armature`) |
|
||||
|
||||
@@ -147,8 +147,8 @@ Example:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `--font` | Primary font family |
|
||||
| `--mono` | Monospace font family |
|
||||
| `--font` | Primary font family (self-hosted, no external requests) |
|
||||
| `--mono` | Monospace font family (self-hosted) |
|
||||
| `--radius-sm` | Small border-radius (4px) — badges, inline controls |
|
||||
| `--radius` | Default border-radius (8px) — buttons, inputs, cards |
|
||||
| `--radius-lg` | Large border-radius (12px) — modals, dialogs, large cards |
|
||||
|
||||
@@ -79,6 +79,7 @@ Every package has a `manifest.json` at its root. Example for a surface:
|
||||
| `settings` | no | User-configurable settings schema |
|
||||
| `exports` | libraries | Functions exported for other packages |
|
||||
| `hooks` | no | Event bus subscriptions |
|
||||
| `config_section` | no | Settings/Admin panel injection (see below) |
|
||||
| `schema_version` | no | Integer for additive schema migrations |
|
||||
|
||||
## db_tables Schema
|
||||
@@ -138,24 +139,73 @@ Only `path` and `method` are required. All other fields are optional. Malformed
|
||||
|
||||
## Starlark Sandbox API
|
||||
|
||||
Starlark scripts run server-side with a CPU budget and memory ceiling. Available modules (granted per-permission by admin):
|
||||
Starlark scripts run server-side with a 1M operation budget and no
|
||||
filesystem access. See the [Starlark Reference](STARLARK-REFERENCE) for
|
||||
the complete module catalog, function signatures, and permission gates.
|
||||
|
||||
| Module | Permission | API |
|
||||
|--------|-----------|-----|
|
||||
| `db` | `db.write` | `db.query(table, filters)`, `db.insert(table, row)`, `db.update(table, id, row)`, `db.delete(table, id)` |
|
||||
| `http` | `http` | `http.get(url)`, `http.post(url, body)` -- SSRF-safe, no private IPs by default |
|
||||
| `notifications` | `notifications` | `notifications.send(user_id, title, body)` |
|
||||
| `secrets` | `secrets` | `secrets.get(connection_type)` -- reads from the credential vault |
|
||||
| `api` | (implicit) | Registers HTTP routes at `/s/:slug/api/*path` |
|
||||
| `realtime` | `realtime.publish` | `realtime.publish(channel, event, data)` -- push to WebSocket clients |
|
||||
## config_section — Settings Panel Injection
|
||||
|
||||
The sandbox cannot spawn goroutines, access the filesystem, or import arbitrary packages.
|
||||
Packages can inject configuration panels into the Settings, Admin, or
|
||||
Team Admin surfaces. Declare `config_section` in `manifest.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"config_section": {
|
||||
"label": "My Config",
|
||||
"icon": "M12 2L2 7l10 5 10-5-10-5z",
|
||||
"component": "js/config.js",
|
||||
"surfaces": ["settings", "admin"],
|
||||
"category": "system"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `label` | yes | Navigation label shown in the sidebar or tab list |
|
||||
| `icon` | no | SVG path data for the nav icon |
|
||||
| `component` | no | JS asset path (default: `js/config.js`). Must `export default` a Preact component. |
|
||||
| `surfaces` | yes | Target surfaces: `"settings"`, `"admin"`, `"team-admin"` |
|
||||
| `category` | no | Admin surface category tab (default: `"system"`). Ignored for settings/team-admin. |
|
||||
|
||||
**How it works:**
|
||||
|
||||
1. At page load, the backend scans all enabled packages for `config_section`
|
||||
entries targeting the current surface.
|
||||
2. Matching sections are injected into the page as `__CONFIG_SECTIONS__`.
|
||||
3. The frontend dynamically imports the component module and renders it
|
||||
as an additional tab/section.
|
||||
4. The component receives `{ packageId, teamId }` as props.
|
||||
|
||||
**Example component** (`js/config.js`):
|
||||
|
||||
```javascript
|
||||
const { html } = window;
|
||||
const { useState, useEffect } = hooks;
|
||||
|
||||
export default function MyConfig({ packageId }) {
|
||||
const [val, setVal] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
sw.api.ext(packageId).get('/settings').then(r => setVal(r.value));
|
||||
}, []);
|
||||
|
||||
return html`<div>
|
||||
<label>API Key</label>
|
||||
<input value=${val} onInput=${e => setVal(e.target.value)} />
|
||||
<button onClick=${() => sw.api.ext(packageId).put('/settings', { value: val })}>Save</button>
|
||||
</div>`;
|
||||
}
|
||||
```
|
||||
|
||||
## Permissions Model
|
||||
|
||||
Extensions declare required permissions in `manifest.json`. The admin must grant each permission before the extension can use the corresponding module. Permission status is visible in Admin > Packages > Permissions.
|
||||
Extensions declare required permissions in `manifest.json`. The admin
|
||||
must grant each permission before the extension can use the corresponding
|
||||
sandbox module. Permission status is visible in **Admin > Packages > Permissions**.
|
||||
|
||||
Kernel permissions for users/groups: `extension.use`, `extension.install`, `workflow.create`, `workflow.submit`, `admin.view`, `token.unlimited`.
|
||||
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for the full RBAC
|
||||
model, user permission slugs, and settings cascade.
|
||||
|
||||
## File Structure
|
||||
|
||||
|
||||
256
docs/FRONTEND-JS-GUIDE.md
Normal file
256
docs/FRONTEND-JS-GUIDE.md
Normal file
@@ -0,0 +1,256 @@
|
||||
# Frontend JS Guide
|
||||
|
||||
Armature extensions run in the browser using **Preact + htm** — a 3 KB
|
||||
runtime with no build step. The kernel provides a rich SDK at `window.sw`
|
||||
that extensions use for API calls, auth, events, theming, and UI.
|
||||
|
||||
## Getting started
|
||||
|
||||
Extension surfaces are ES modules loaded via `<script type="module">`.
|
||||
The SDK is available on `window.sw` after the `sw:ready` DOM event:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('sw:ready', () => {
|
||||
const { html } = window;
|
||||
const mount = document.getElementById('my-mount');
|
||||
preact.render(html`<${App} />`, mount);
|
||||
});
|
||||
```
|
||||
|
||||
Or use the global `hooks` object for Preact hooks:
|
||||
|
||||
```javascript
|
||||
const { useState, useEffect } = hooks;
|
||||
```
|
||||
|
||||
## SDK modules
|
||||
|
||||
All modules live on the `window.sw` object. They are frozen after boot
|
||||
and available to every extension.
|
||||
|
||||
### sw.api — REST client
|
||||
|
||||
Generic escape hatches for any endpoint:
|
||||
|
||||
```javascript
|
||||
sw.api.get('/api/v1/docs')
|
||||
sw.api.post('/api/v1/teams', { name: 'Eng' })
|
||||
sw.api.put(path, body)
|
||||
sw.api.patch(path, body)
|
||||
sw.api.del(path)
|
||||
sw.api.upload(path, file)
|
||||
sw.api.stream(path, body, signal)
|
||||
```
|
||||
|
||||
All methods auto-inject auth tokens and return unwrapped `data` from
|
||||
`{ data: ... }` response envelopes.
|
||||
|
||||
**Domain namespaces** provide typed CRUD methods:
|
||||
|
||||
| Namespace | Key methods |
|
||||
|-----------|-------------|
|
||||
| `sw.api.auth` | `login`, `register`, `refresh`, `logout` |
|
||||
| `sw.api.teams` | `list`, `get`, `create`, `members`, `workflows`, `assignments` |
|
||||
| `sw.api.workflows` | `list`, `get`, `stages`, `instances`, `advance`, `cancel` |
|
||||
| `sw.api.channels` | `list`, `get`, `create`, `update`, `del` |
|
||||
| `sw.api.notifications` | `list`, `unreadCount`, `markRead`, `markAllRead` |
|
||||
| `sw.api.admin` | Sub-objects for `users`, `teams`, `groups`, `packages`, `backup`, etc. |
|
||||
| `sw.api.users` | `search`, `resolve` |
|
||||
| `sw.api.connections` | `list`, `get`, `create`, `resolve` |
|
||||
| `sw.api.ext(pkgId)` | Scoped client for extension API routes: `get`, `post`, `put`, `del` |
|
||||
|
||||
### sw.auth — Authentication state
|
||||
|
||||
```javascript
|
||||
sw.auth.isAuthenticated // boolean
|
||||
sw.auth.user // { id, username, display_name, email, role, avatar }
|
||||
sw.auth.permissions // Set<string>
|
||||
sw.auth.teams // Array<{ id, name, role }>
|
||||
sw.auth.groups // Array<{ id, name, permissions }>
|
||||
```
|
||||
|
||||
Lifecycle: `sw.auth.login(login, pw)`, `sw.auth.logout()`, `sw.auth.refresh()`.
|
||||
|
||||
### sw.can — RBAC gates
|
||||
|
||||
```javascript
|
||||
sw.can('workflow.create') // true if user has permission
|
||||
sw.isAdmin // true if surface.admin.access granted
|
||||
sw.isTeamAdmin(teamId) // true if admin role in team
|
||||
```
|
||||
|
||||
Use these to conditionally render UI elements.
|
||||
|
||||
### sw.on / sw.off / sw.emit — Event bus
|
||||
|
||||
```javascript
|
||||
const unsub = sw.on('theme.changed', (payload) => { ... });
|
||||
sw.once('auth.login', (user) => { ... });
|
||||
sw.off('theme.changed'); // remove all listeners
|
||||
sw.off('theme.changed', fn); // remove specific listener
|
||||
sw.emit('my.event', { data });
|
||||
```
|
||||
|
||||
The event bus bridges to the WebSocket — server-emitted events
|
||||
(e.g. `notification.created`, `workflow.sla_breach`) arrive here.
|
||||
|
||||
### sw.theme — Theme control
|
||||
|
||||
```javascript
|
||||
sw.theme.current // 'dark' or 'light' (resolved)
|
||||
sw.theme.mode // 'dark', 'light', or 'system'
|
||||
sw.theme.set('dark')
|
||||
sw.theme.on('change', (theme) => { ... })
|
||||
sw.theme.tokens // live CSS variables as camelCase JS object
|
||||
```
|
||||
|
||||
### sw.storage — Namespaced localStorage
|
||||
|
||||
```javascript
|
||||
const store = sw.storage.local('my-extension');
|
||||
store.set('key', { complex: 'value' });
|
||||
store.get('key') // parsed object
|
||||
store.remove('key')
|
||||
store.keys() // ['key', ...]
|
||||
store.clear()
|
||||
```
|
||||
|
||||
### sw.realtime — WebSocket pub/sub
|
||||
|
||||
```javascript
|
||||
const unsub = sw.realtime.subscribe('my-channel', 'item.updated', (data) => { ... });
|
||||
// Or subscribe to all events on a channel:
|
||||
const unsub = sw.realtime.subscribe('my-channel', (event, data) => { ... });
|
||||
```
|
||||
|
||||
### sw.slots — UI slot registry
|
||||
|
||||
Register components into named shell slots (e.g. toolbar areas):
|
||||
|
||||
```javascript
|
||||
const unreg = sw.slots.register('topbar-actions', {
|
||||
id: 'my-button',
|
||||
component: MyButton,
|
||||
priority: 10,
|
||||
});
|
||||
```
|
||||
|
||||
### sw.actions — Named action registry
|
||||
|
||||
```javascript
|
||||
sw.actions.register('copy-link', {
|
||||
handler: async (url) => navigator.clipboard.writeText(url),
|
||||
label: 'Copy Link',
|
||||
icon: 'M12 2...',
|
||||
});
|
||||
await sw.actions.run('copy-link', someUrl);
|
||||
```
|
||||
|
||||
### sw.pipe — Filter pipeline
|
||||
|
||||
Three-stage pipeline for message processing:
|
||||
|
||||
```javascript
|
||||
sw.pipe.pre(10, async (ctx) => { /* pre-process */ return ctx; });
|
||||
sw.pipe.stream(10, async (ctx) => { /* streaming */ return ctx; });
|
||||
sw.pipe.render(10, async (ctx) => { /* post-render */ return ctx; });
|
||||
```
|
||||
|
||||
### sw.renderers — Block and post renderers
|
||||
|
||||
Register custom renderers for fenced code blocks or post-processing:
|
||||
|
||||
```javascript
|
||||
sw.renderers.register('mermaid', {
|
||||
type: 'block',
|
||||
pattern: /^mermaid$/,
|
||||
render: (code, container) => { /* render diagram */ },
|
||||
});
|
||||
|
||||
sw.renderers.register('linkify', {
|
||||
type: 'post',
|
||||
render: (container) => { /* post-process rendered HTML */ },
|
||||
});
|
||||
```
|
||||
|
||||
### sw.markdown — Unified rendering
|
||||
|
||||
```javascript
|
||||
const html = sw.markdown.renderSync(markdownString, { sanitize: false });
|
||||
await sw.markdown.render(markdownString); // async variant
|
||||
sw.markdown.ready // boolean — true after preload
|
||||
```
|
||||
|
||||
Uses marked + DOMPurify + registered `sw.renderers`.
|
||||
|
||||
### sw.users — Identity resolution
|
||||
|
||||
```javascript
|
||||
const user = await sw.users.resolve(userId);
|
||||
const map = await sw.users.resolveMany([id1, id2]);
|
||||
sw.users.displayName(userObj) // display_name || username || 'Unknown'
|
||||
```
|
||||
|
||||
Results are cached for 60 seconds. Batch fetches use the server's
|
||||
bulk resolve endpoint.
|
||||
|
||||
### sw.testing — Test framework
|
||||
|
||||
For writing package runner tests:
|
||||
|
||||
```javascript
|
||||
sw.testing.suite('CRUD', async (s) => {
|
||||
s.before(async () => { /* setup */ });
|
||||
s.after(async () => { /* cleanup */ });
|
||||
|
||||
s.test('creates item', async (t) => {
|
||||
const resp = await sw.api.post('/api/v1/items', { name: 'test' });
|
||||
t.assert.status(resp, 200);
|
||||
t.assert.ok(resp.id);
|
||||
s.track('item', resp.id); // auto-cleanup in afterAll
|
||||
});
|
||||
});
|
||||
await sw.testing.run();
|
||||
```
|
||||
|
||||
### sw.shell — Topbar API
|
||||
|
||||
The kernel injects a two-slot topbar into every extension surface.
|
||||
Extensions customize it via:
|
||||
|
||||
```javascript
|
||||
sw.shell.topbar.setTitle('My Surface'); // text in left slot
|
||||
sw.shell.topbar.setSlot(html`<${TabBar} />`); // center slot content
|
||||
sw.shell.topbar.hide(); // full-bleed mode
|
||||
sw.shell.topbar.show(); // restore topbar
|
||||
```
|
||||
|
||||
### sw.toast / sw.confirm / sw.prompt — UI primitives
|
||||
|
||||
```javascript
|
||||
sw.toast('Saved!', 'success'); // success | error | info
|
||||
sw.toast('Something broke', 'error', 5000); // custom duration
|
||||
|
||||
const ok = await sw.confirm('Delete this item?');
|
||||
const name = await sw.prompt('Enter name', 'default value');
|
||||
```
|
||||
|
||||
## Shell topbar patterns
|
||||
|
||||
Every surface uses one of three patterns:
|
||||
|
||||
| Pattern | Description | Example |
|
||||
|---------|-------------|---------|
|
||||
| **A — Default** | Shell title only. No center slot content. | Docs |
|
||||
| **B — Flat tabs** | `setTitle()` + tabs in center slot via `setSlot()`. Full-width content. | Settings, Team Admin |
|
||||
| **C — Category tabs + sidebar** | `setTitle()` + category tabs in center slot. Surface-owned sidebar below. | Admin |
|
||||
|
||||
The shell provides the home link, notification bell, and user menu
|
||||
on every surface for free.
|
||||
|
||||
## Extension CSS contract
|
||||
|
||||
Extensions must prefix all CSS classes with `.ext-{slug}-` to avoid
|
||||
conflicts with kernel styles. See the [Extension CSS](EXTENSION-CSS)
|
||||
doc for the full isolation rules, available primitives from
|
||||
`sw-primitives.css`, and spacing tokens.
|
||||
@@ -12,7 +12,7 @@ docker compose up --build
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000). Default credentials: `admin` / `admin`.
|
||||
|
||||
Data persists in the `sb_data` named volume. To reset everything:
|
||||
Data persists in the `armature_data` named volume. To reset everything:
|
||||
|
||||
```bash
|
||||
docker compose down -v
|
||||
|
||||
121
docs/PERMISSIONS-AND-GROUPS.md
Normal file
121
docs/PERMISSIONS-AND-GROUPS.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Permissions & Groups
|
||||
|
||||
Armature uses group-based RBAC. Permissions are granted to groups, and users
|
||||
inherit the union of permissions from all groups they belong to. There are no
|
||||
per-user permission grants — all access flows through group membership.
|
||||
|
||||
## Groups
|
||||
|
||||
### System groups
|
||||
|
||||
| Group | ID | Purpose |
|
||||
|-------|----|---------|
|
||||
| Everyone | `00000000-...0001` | Implicit membership for every authenticated user. Default permissions: `extension.use`, `workflow.submit`. |
|
||||
| Admins | `00000000-...0002` | Full platform access. Members receive all seven permission slugs. Replaces the legacy `role = admin` check. |
|
||||
|
||||
Every new user is automatically added to **Everyone** on registration.
|
||||
Admin status is granted by adding a user to the **Admins** group in
|
||||
**Admin > People > Groups**.
|
||||
|
||||
### Custom groups
|
||||
|
||||
Administrators can create additional groups under **Admin > People > Groups**.
|
||||
Each custom group has:
|
||||
|
||||
- **Name** — display label
|
||||
- **Description** — purpose (shown in admin UI)
|
||||
- **Scope** — always `global` (team-scoped groups reserved for future use)
|
||||
- **Permissions** — zero or more permission slugs from the table below
|
||||
|
||||
## Permission slugs
|
||||
|
||||
Seven platform permissions control access to kernel features:
|
||||
|
||||
| Slug | Description |
|
||||
|------|-------------|
|
||||
| `surface.admin.access` | Full admin panel access (tabs, settings, package management) |
|
||||
| `admin.view` | Read-only admin panel access (monitoring, health, audit log) |
|
||||
| `extension.use` | Use installed extension surfaces and libraries |
|
||||
| `extension.install` | Install, update, enable, and disable packages |
|
||||
| `workflow.create` | Create and edit workflow definitions |
|
||||
| `workflow.submit` | Submit instances to public-link workflows |
|
||||
| `token.unlimited` | Bypass per-user token budgets (API rate limiting) |
|
||||
|
||||
Permissions follow a `domain.action` naming convention.
|
||||
|
||||
## Permission resolution
|
||||
|
||||
When a request arrives, the kernel resolves the effective permission set:
|
||||
|
||||
1. Fetch all groups the user belongs to (including Everyone).
|
||||
2. Union all permission arrays across those groups.
|
||||
3. Cache the result for the duration of the request.
|
||||
|
||||
A user has a permission if **any** of their groups grants it.
|
||||
|
||||
Frontend code checks permissions via `sw.can('slug')` — see the
|
||||
[Frontend JS Guide](FRONTEND-JS-GUIDE) for details.
|
||||
|
||||
## Extension permissions
|
||||
|
||||
Separate from user permissions, each **package** can request sandbox
|
||||
capabilities. These are granted per-package in **Admin > Packages**:
|
||||
|
||||
| Permission | Grants |
|
||||
|------------|--------|
|
||||
| `db.read` | Query `ext_data` tables (read-only) |
|
||||
| `db.write` | Insert, update, and delete rows in `ext_data` tables |
|
||||
| `api.http` | Make outbound HTTP requests from Starlark |
|
||||
| `notifications.send` | Send in-app notifications to users |
|
||||
| `secrets.read` | Read admin-configured extension secrets |
|
||||
| `realtime.publish` | Publish WebSocket events to subscribed clients |
|
||||
| `connections.read` | Read external connection configs (decrypted) |
|
||||
| `workflow.access` | Read workflow definitions and instances |
|
||||
|
||||
See the [Starlark Reference](STARLARK-REFERENCE) for how these
|
||||
map to sandbox modules.
|
||||
|
||||
## Settings cascade
|
||||
|
||||
Package settings use a three-tier resolution model:
|
||||
|
||||
```
|
||||
user override → team override → global default
|
||||
```
|
||||
|
||||
At each tier:
|
||||
|
||||
- **Global** — set by admins in **Admin > Packages > Settings**
|
||||
- **Team** — set by team admins in **Team Admin > Settings**
|
||||
- **User** — set by users in **Settings > Extensions**
|
||||
|
||||
### The `user_overridable` flag
|
||||
|
||||
Each setting key in a package manifest can declare `user_overridable`:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": [
|
||||
{ "key": "theme", "user_overridable": true },
|
||||
{ "key": "api_endpoint", "user_overridable": false }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `true` (default) — team and user scopes can override the global value.
|
||||
- `false` — only the global (admin) value is used. Team and user values
|
||||
are silently ignored during resolution.
|
||||
|
||||
This gives administrators a lock mechanism: set `user_overridable: false`
|
||||
on security-sensitive keys to prevent lower scopes from changing them,
|
||||
while allowing cosmetic preferences to flow freely.
|
||||
|
||||
### Resolution algorithm
|
||||
|
||||
1. Start with the global value for each key.
|
||||
2. For each key where `user_overridable` is true (or undeclared):
|
||||
- If a team-scoped value exists, it overrides global.
|
||||
- If a user-scoped value exists, it overrides team.
|
||||
3. For keys where `user_overridable` is false:
|
||||
- Team and user values are discarded.
|
||||
4. Unknown keys (not in schema) default to overridable.
|
||||
232
docs/STARLARK-REFERENCE.md
Normal file
232
docs/STARLARK-REFERENCE.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Starlark Reference
|
||||
|
||||
Armature extensions can include Starlark scripts for server-side logic.
|
||||
Starlark is a Python-like language designed for configuration and
|
||||
embedding — see the [official spec](https://github.com/google/starlark-go).
|
||||
|
||||
## Sandbox constraints
|
||||
|
||||
- **No `while` loops** — use `for` with bounded ranges.
|
||||
- **No `load()`** — use `lib.require()` for library dependencies.
|
||||
- **Max steps:** 1,000,000 bytecode operations per execution.
|
||||
- **No filesystem or OS access** — all I/O goes through gated modules.
|
||||
- **Deterministic** — same input produces same output (no `random`, no `time`).
|
||||
|
||||
## Always-available modules
|
||||
|
||||
These modules are injected into every script with no permission required.
|
||||
|
||||
### json
|
||||
|
||||
Standard Starlark JSON module.
|
||||
|
||||
```python
|
||||
data = json.decode('{"key": "value"}')
|
||||
text = json.encode({"key": "value"})
|
||||
```
|
||||
|
||||
### settings
|
||||
|
||||
Read resolved package settings (global → team → user cascade).
|
||||
|
||||
```python
|
||||
val = settings.get("theme", "light")
|
||||
# Returns the resolved value, or the default if unset.
|
||||
```
|
||||
|
||||
The cascade respects the `user_overridable` flag from the package manifest.
|
||||
See [Permissions & Groups](PERMISSIONS-AND-GROUPS) for details.
|
||||
|
||||
### lib
|
||||
|
||||
Load exported functions from library packages.
|
||||
|
||||
```python
|
||||
helpers = lib.require("my-utils")
|
||||
result = helpers.format_date("2026-01-15")
|
||||
```
|
||||
|
||||
Requirements:
|
||||
- The library must be declared in your package manifest's `dependencies`.
|
||||
- The library must be type `library`, status `active`, tier `starlark`.
|
||||
- Circular dependencies are detected and rejected.
|
||||
- Results are cached per execution (calling `require` twice returns the
|
||||
same object).
|
||||
|
||||
## Permission-gated modules
|
||||
|
||||
These modules are only available if the package has the corresponding
|
||||
permission granted in **Admin > Packages**.
|
||||
|
||||
### secrets
|
||||
|
||||
**Permission:** `secrets.read`
|
||||
|
||||
Read admin-configured secrets for this package.
|
||||
|
||||
```python
|
||||
api_key = secrets.get("OPENAI_KEY") # str or None
|
||||
all_keys = secrets.list() # list of key names
|
||||
```
|
||||
|
||||
Secrets are set in **Admin > Packages > Secrets** and scoped per package.
|
||||
|
||||
### notifications
|
||||
|
||||
**Permission:** `notifications.send`
|
||||
|
||||
Send in-app notifications to users.
|
||||
|
||||
```python
|
||||
notifications.send(
|
||||
user_id, # str — target user UUID
|
||||
title, # str — notification title
|
||||
body="", # str — optional body text
|
||||
type="extension.notify" # str — notification type
|
||||
)
|
||||
```
|
||||
|
||||
### db
|
||||
|
||||
**Permission:** `db.read` (queries) or `db.write` (mutations)
|
||||
|
||||
Read and write extension data tables. All tables are automatically
|
||||
namespaced as `ext_{package_id}_{table_name}`.
|
||||
|
||||
#### Read operations
|
||||
|
||||
```python
|
||||
# Query with filters, ordering, and pagination
|
||||
rows = db.query(
|
||||
"tasks", # table name (without prefix)
|
||||
filters={"status": "open"}, # equality WHERE clauses
|
||||
order="-created_at", # column name (prefix - for DESC)
|
||||
limit=50, # max 1000
|
||||
before={"created_at": ts}, # range: column < value
|
||||
after={"created_at": ts}, # range: column > value
|
||||
search_like={"title": "%bug%"} # LIKE/ILIKE search
|
||||
)
|
||||
|
||||
# Read from system views (read-only)
|
||||
users = db.view("users", filters={"display_name": "Alice"}, limit=10)
|
||||
channels = db.view("channels", limit=100)
|
||||
|
||||
# List all tables owned by this package
|
||||
tables = db.list_tables()
|
||||
```
|
||||
|
||||
Available views: `users`, `channels`.
|
||||
|
||||
#### Write operations
|
||||
|
||||
```python
|
||||
row = db.insert("tasks", {"title": "Fix bug", "status": "open"})
|
||||
# Returns the inserted row dict (with generated id, created_at)
|
||||
|
||||
db.update("tasks", row_id, {"status": "closed"})
|
||||
# Returns True on success
|
||||
|
||||
db.delete("tasks", row_id)
|
||||
# Returns True on success
|
||||
```
|
||||
|
||||
### http
|
||||
|
||||
**Permission:** `api.http`
|
||||
|
||||
Make outbound HTTP requests.
|
||||
|
||||
```python
|
||||
resp = http.get("https://api.example.com/data", headers={"Authorization": "Bearer ..."})
|
||||
resp = http.post(url, body='{"key": "val"}', headers={"Content-Type": "application/json"})
|
||||
resp = http.put(url, body="...", headers={})
|
||||
resp = http.delete(url, headers={})
|
||||
resp = http.request("PATCH", url, body="...", headers={})
|
||||
```
|
||||
|
||||
Response dict:
|
||||
|
||||
```python
|
||||
{
|
||||
"status": 200,
|
||||
"headers": {"content-type": "application/json"},
|
||||
"body": "..." # capped at 1 MB
|
||||
}
|
||||
```
|
||||
|
||||
**Security:**
|
||||
- Private/loopback IPs are blocked (SSRF protection).
|
||||
- Packages can declare `network_access.allow` (allowlist) or
|
||||
`network_access.block` (blocklist) in their manifest.
|
||||
- Max 10 redirects. 10-second timeout. 1 MB response body limit.
|
||||
|
||||
### realtime
|
||||
|
||||
**Permission:** `realtime.publish`
|
||||
|
||||
Publish WebSocket events to subscribed clients.
|
||||
|
||||
```python
|
||||
realtime.publish(
|
||||
"my-channel", # channel name
|
||||
"item.updated", # event label
|
||||
{"id": "abc"} # payload dict (max 7 KB)
|
||||
)
|
||||
```
|
||||
|
||||
The payload is automatically tagged with `_pkg: package_id`.
|
||||
|
||||
### connections
|
||||
|
||||
**Permission:** `connections.read`
|
||||
|
||||
Read external connection configurations (secrets are decrypted).
|
||||
|
||||
```python
|
||||
conn = connections.get("postgres", "main-db")
|
||||
# Returns dict with id, type, name, scope, plus flattened config fields
|
||||
# Returns None if not found
|
||||
|
||||
all_pg = connections.list("postgres")
|
||||
# Returns list of connection dicts
|
||||
```
|
||||
|
||||
Connections are resolved via scope chain: personal → team → global.
|
||||
|
||||
### workflow
|
||||
|
||||
**Permission:** `workflow.access`
|
||||
|
||||
Read workflow definitions and instances (read-only from Starlark;
|
||||
mutations go through the HTTP API).
|
||||
|
||||
```python
|
||||
defn = workflow.get_definition(workflow_id)
|
||||
# Returns dict: id, name, slug, entry_mode, is_active, version, stages[]
|
||||
|
||||
inst = workflow.get_instance(instance_id)
|
||||
# Returns dict: id, workflow_id, current_stage, status, stage_data, ...
|
||||
|
||||
instances = workflow.list_instances(workflow_id, status="active")
|
||||
# Returns list of instance dicts
|
||||
```
|
||||
|
||||
## Example: automated stage hook
|
||||
|
||||
A simple hook that reads a setting, queries data, and advances:
|
||||
|
||||
```python
|
||||
def on_run(ctx):
|
||||
threshold = settings.get("approval_threshold", 1000)
|
||||
amount = ctx["stage_data"].get("amount", 0)
|
||||
|
||||
if amount > threshold:
|
||||
notifications.send(
|
||||
ctx["started_by"],
|
||||
"High-value submission",
|
||||
body="Amount %d exceeds threshold." % amount,
|
||||
)
|
||||
return {"advance": True, "data": {"needs_review": True}}
|
||||
|
||||
return {"advance": True, "data": {"needs_review": False}}
|
||||
```
|
||||
@@ -54,7 +54,7 @@ and register with the SDK through `sw.renderers`:
|
||||
},
|
||||
render(lang, code, container) {
|
||||
container.innerHTML =
|
||||
'<div style="padding:12px;background:var(--bg-2);' +
|
||||
'<div style="padding:12px;background:var(--bg-secondary);' +
|
||||
'border:1px solid var(--border);border-radius:8px">' +
|
||||
'<strong>Demo:</strong> ' + code +
|
||||
'</div>';
|
||||
@@ -74,7 +74,7 @@ The IIFE wrapper keeps variables out of global scope. `sw.renderers.register`
|
||||
takes a name and an options object: `type: 'block'` targets fenced code blocks,
|
||||
`match` checks the language tag, and `render` receives the language, raw code,
|
||||
and a container element. The `sw:ready` event fires once the SDK initializes;
|
||||
if already loaded, register immediately. Use CSS variables like `var(--bg-2)`
|
||||
if already loaded, register immediately. Use CSS variables like `var(--bg-secondary)`
|
||||
and `var(--border)` to follow the active theme.
|
||||
|
||||
## Step 4: Package It
|
||||
|
||||
193
docs/WORKFLOWS.md
Normal file
193
docs/WORKFLOWS.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# Workflows
|
||||
|
||||
Workflows are multi-stage processes with team assignment, validation gates,
|
||||
SLA enforcement, and optional Starlark automation. They are managed in
|
||||
**Team Admin > Workflows**.
|
||||
|
||||
## Core concepts
|
||||
|
||||
| Concept | Description |
|
||||
|---------|-------------|
|
||||
| **Definition** | A named template: stages, entry mode, staleness timeout. Created per-team or adopted from global definitions. |
|
||||
| **Stage** | One step in the workflow. Has a mode, audience, optional team assignment, and optional SLA. |
|
||||
| **Instance** | A running copy of a definition. Pins a published version snapshot and tracks accumulated stage data. |
|
||||
| **Assignment** | A queue entry linking an instance stage to a team member. Claim → work → complete. |
|
||||
| **Signoff** | An approval or rejection recorded against an instance stage (multi-party validation). |
|
||||
|
||||
## Entry modes
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `team_only` | Only authenticated team members can start instances. |
|
||||
| `public_link` | Anyone with the public URL can start an instance. The first stage must have `audience: public`. An `entry_token` is issued for the anonymous submitter to resume later. |
|
||||
|
||||
Public entry URL format:
|
||||
```
|
||||
{origin}/api/v1/public/workflows/{workflow_id}/start
|
||||
```
|
||||
|
||||
## Stage modes
|
||||
|
||||
Each stage has a **mode** that determines how it progresses:
|
||||
|
||||
| Mode | Description |
|
||||
|------|-------------|
|
||||
| `form` | User submits structured data. Stage data is accumulated into the instance. |
|
||||
| `review` | Multi-party sign-off gate. Requires configured approvals before advancing. |
|
||||
| `delegated` | Assigned to a team member queue. The assignee claims, works, and completes. |
|
||||
| `automated` | Starlark hook executes without user interaction. Can chain up to 10 consecutive automated stages. |
|
||||
|
||||
## Stage types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `simple` | Linear — always advances to the next ordinal. |
|
||||
| `dynamic` | Conditional — evaluates branch rules against stage data to pick the next stage. |
|
||||
| `automated` | Combined with mode `automated` for fully scripted stages. |
|
||||
|
||||
## Audiences
|
||||
|
||||
| Audience | Description |
|
||||
|----------|-------------|
|
||||
| `team` | Only authenticated team members can interact. |
|
||||
| `public` | Anonymous users can interact (used with `public_link` entry). |
|
||||
| `system` | System-generated stages, no direct user interaction. |
|
||||
|
||||
## Team assignment
|
||||
|
||||
When a stage has `assignment_team_id` set, the engine creates an
|
||||
**assignment** record:
|
||||
|
||||
1. Assignment enters the queue with status `unassigned`.
|
||||
2. A team member **claims** the assignment (status → `claimed`).
|
||||
3. The assignee works the stage and **completes** it (status → `completed`).
|
||||
4. The engine auto-advances to the next stage.
|
||||
|
||||
A **required role** can restrict who may claim:
|
||||
- Set `stage_config.required_role` to a team role name (e.g. `"reviewer"`).
|
||||
- Only members with that role can claim the assignment.
|
||||
|
||||
Team roles are configured in **Team Admin > Settings > Roles**.
|
||||
|
||||
## Signoff gates (multi-party validation)
|
||||
|
||||
Review-mode stages can require multiple approvals before advancing.
|
||||
Configure via `stage_config.validation`:
|
||||
|
||||
```json
|
||||
{
|
||||
"validation": {
|
||||
"required_approvals": 2,
|
||||
"required_role": "approver",
|
||||
"reject_action": "cancel"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| `required_approvals` | Minimum approve decisions needed to advance. |
|
||||
| `required_role` | Only members with this team role can sign off. Empty = any member. |
|
||||
| `reject_action` | What happens on rejection: `"cancel"` (default) cancels the instance, or a stage name to reroute. |
|
||||
|
||||
Each signoff records: user, decision (`approve` or `reject`), optional comment, timestamp.
|
||||
|
||||
## SLA enforcement
|
||||
|
||||
Two timeout mechanisms run in a background scanner (every 5 minutes):
|
||||
|
||||
### Per-stage SLA
|
||||
|
||||
Set `sla_seconds` on a stage. When an instance has been in that stage
|
||||
longer than the threshold:
|
||||
|
||||
- `sla_breached` flag is set in instance metadata.
|
||||
- A `workflow.sla_breach` WebSocket event is emitted.
|
||||
- The instance is **not** auto-cancelled — breaches are informational.
|
||||
|
||||
### Per-workflow staleness
|
||||
|
||||
Set `staleness_timeout_hours` on the workflow definition. When an instance
|
||||
has not been updated for longer than the threshold:
|
||||
|
||||
- Instance status is set to `stale`.
|
||||
- All open assignments are cancelled.
|
||||
- A `workflow.stale` WebSocket event is emitted.
|
||||
|
||||
## Branch rules
|
||||
|
||||
Dynamic stages evaluate conditions against accumulated `stage_data`
|
||||
to determine the next stage. Rules are a JSON array on the stage:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "field": "priority", "op": "eq", "value": "high", "target_stage": "escalation" },
|
||||
{ "field": "amount", "op": "gt", "value": 10000, "target_stage": "manager-review" }
|
||||
]
|
||||
```
|
||||
|
||||
First matching rule wins. If no rules match, the next ordinal stage is used.
|
||||
|
||||
### Operators
|
||||
|
||||
| Op | Description |
|
||||
|----|-------------|
|
||||
| `eq` | Equal (string-normalized) |
|
||||
| `neq` | Not equal |
|
||||
| `gt`, `lt`, `gte`, `lte` | Numeric comparisons |
|
||||
| `exists` | Field is present in stage data |
|
||||
| `not_exists` | Field is absent |
|
||||
| `in` | Value is in a list |
|
||||
| `contains` | String contains substring |
|
||||
|
||||
`target_stage` can be a stage name (case-insensitive) or a numeric ordinal.
|
||||
|
||||
## Publishing
|
||||
|
||||
Workflows have a draft/publish lifecycle:
|
||||
|
||||
1. Edit stages and configuration in the workflow editor (draft state).
|
||||
2. **Publish** creates a versioned snapshot of all stages.
|
||||
3. New instances pin the latest published version.
|
||||
4. Editing stages after publishing does not affect running instances.
|
||||
|
||||
Version numbers auto-increment. The snapshot preserves the complete
|
||||
stage definition array at publish time.
|
||||
|
||||
## Starlark hooks
|
||||
|
||||
Automated stages execute a Starlark script via the `starlark_hook` field:
|
||||
|
||||
```
|
||||
package_id:entry_point
|
||||
```
|
||||
|
||||
For example: `my-automation:on_review` calls the `on_review` function
|
||||
in the `my-automation` package. If no entry point is specified,
|
||||
`on_run` is used.
|
||||
|
||||
The hook receives a context dict:
|
||||
|
||||
```python
|
||||
{
|
||||
"instance_id": "...",
|
||||
"current_stage": "...",
|
||||
"workflow_id": "...",
|
||||
"started_by": "...",
|
||||
"stage_data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
The hook returns a dict controlling what happens next:
|
||||
|
||||
| Key | Effect |
|
||||
|-----|--------|
|
||||
| `advance: True` | Auto-advance to the next stage |
|
||||
| `data: { ... }` | Merge into stage data for the next stage |
|
||||
| `error: "msg"` | Set instance status to `error` and halt |
|
||||
|
||||
Up to 10 consecutive automated stages can chain before the engine
|
||||
stops with an error (cycle guard).
|
||||
|
||||
See the [Starlark Reference](STARLARK-REFERENCE) for available
|
||||
sandbox modules.
|
||||
Reference in New Issue
Block a user