create-flowdular 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-template/.agents/skills/module-new/SKILL.md +20 -19
- package/agent-template/.agents/skills/module-update/SKILL.md +4 -2
- package/agent-template/.agents/skills/spec-interview/SKILL.md +21 -20
- package/agent-template/.agents/skills/ux-design/SKILL.md +23 -1
- package/agent-template/.ai/platform-capabilities.md +8 -4
- package/agent-template/.ai/policies/capabilities.yaml +1 -0
- package/agent-template/.ai/skills/module-new/SKILL.md +20 -19
- package/agent-template/.ai/skills/module-update/SKILL.md +4 -2
- package/agent-template/.ai/skills/spec-interview/SKILL.md +21 -20
- package/agent-template/.ai/skills/ux-design/SKILL.md +23 -1
- package/agent-template/.claude/skills/module-new/SKILL.md +20 -19
- package/agent-template/.claude/skills/module-update/SKILL.md +4 -2
- package/agent-template/.claude/skills/spec-interview/SKILL.md +21 -20
- package/agent-template/.claude/skills/ux-design/SKILL.md +23 -1
- package/agent-template/docs/cli.md +3 -0
- package/agent-template/docs/configuration.md +17 -0
- package/agent-template/docs/design-system.md +8 -1
- package/agent-template/docs/module-web-surfaces.md +31 -3
- package/package.json +1 -1
- package/template/default/modules/example/module.json +1 -1
- package/template/default/modules/example/package.json +1 -1
- package/template/default/modules/example/spec/module.yaml +1 -1
- package/template/default/package.json +1 -1
- package/template/default/platform/octane.config.ts +26 -2
- package/template/default/platform/package.json +1 -1
|
@@ -20,25 +20,26 @@ Every `acceptanceScenarios[]` entry maps to at least one test in `tests/`. A sce
|
|
|
20
20
|
|
|
21
21
|
Each spec element maps to files:
|
|
22
22
|
|
|
23
|
-
| Spec element | Files it produces
|
|
24
|
-
| ----------------------------- |
|
|
25
|
-
| `entities[]` | `src/domain/types.ts`, `migrations/000N_<module>_<name>.{up,down}.sql`, `src/services/migration.ts`, `src/services/{repository,database-repository}.ts`
|
|
26
|
-
| `entities[].fields[]` | the columns and row mapping above, the validation bounds in `src/api/endpoints.ts`, the form field and table cell in `src/client/*.tsrx`
|
|
27
|
-
| `fields[].unique: tenant` | a `(tenant_id, <field>)` unique index in the migration plus the stable conflict code in the service
|
|
28
|
-
| `entities[].states` | the status column, the transition guard in the service, the `Tag` tone in the view
|
|
29
|
-
| `screens[]` | `src/client/<Pascal>View.tsrx`, a `views` entry and a `navigation` entry in `src/client/contribution.tsrx`, `translations/*.json`
|
|
30
|
-
| `
|
|
31
|
-
| `screens[].
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
23
|
+
| Spec element | Files it produces |
|
|
24
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `entities[]` | `src/domain/types.ts`, `migrations/000N_<module>_<name>.{up,down}.sql`, `src/services/migration.ts`, `src/services/{repository,database-repository}.ts` |
|
|
26
|
+
| `entities[].fields[]` | the columns and row mapping above, the validation bounds in `src/api/endpoints.ts`, the form field and table cell in `src/client/*.tsrx` |
|
|
27
|
+
| `fields[].unique: tenant` | a `(tenant_id, <field>)` unique index in the migration plus the stable conflict code in the service |
|
|
28
|
+
| `entities[].states` | the status column, the transition guard in the service, the `Tag` tone in the view |
|
|
29
|
+
| `screens[]` | `src/client/<Pascal>View.tsrx`, a `views` entry and a `navigation` entry in `src/client/contribution.tsrx`, `translations/*.json` |
|
|
30
|
+
| `design/<screen>.html` | the approved look of that screen: mirror its structure and keep its copy. Render it with `pnpm ui:preview <path>`; absent, design it first with `ux-design` |
|
|
31
|
+
| `screens[].columns`/`filters` | the `TableColumn[]` outside the component, and the controls inside the `Filters` dropdown |
|
|
32
|
+
| `screens[].navigationGroup` | the navigation entry's `group` |
|
|
33
|
+
| `actions[]` | the service method with its error code, the `defineEndpoint` route, the fetch in `src/client/api.ts` |
|
|
34
|
+
| `widgets[]` | a widget component plus a `widgets` entry with its `slot` in `src/client/contribution.tsrx` |
|
|
35
|
+
| `settings[]` | `src/settings.ts` (`defineModuleSettings`) and `settings:` in `src/platform.ts`; a `kind: flag` entry keeps `kind: 'flag'` there, so owners switch it on the Flags tab of Administration, Modules |
|
|
36
|
+
| `agentTools[]` | `src/agent/tools.ts` and the `context.agentTools.register` call, as a separate `agent-tool-design` phase |
|
|
37
|
+
| `research` | `src/research.ts`, `research-fixtures.json`, `requires` of the capabilities, the evidence attach where the `evidenceOwner` record is written |
|
|
38
|
+
| `adapters[]` | `src/adapters/<name>.ts`, the port, the adapter registration, `adapters/<name>.recorded.json`, as a separate `integration-adapter` phase |
|
|
39
|
+
| `templates[]` | `templates/<name>.md`, `templates` in `package.json` `files`, `src/templates.ts` registered through `documents.templates.v1` in `src/platform.ts` |
|
|
40
|
+
| `permissions[]` | `src/acl/permissions.ts`, the endpoint `access.permission`, the client `scope` |
|
|
41
|
+
| `acceptanceScenarios[]` | `tests/module.test.ts` and its siblings, at least one case each |
|
|
42
|
+
| `outOfScope[]`, `decisions[]` | no code. Read them so you do not rebuild a decision or implement a deferred feature. |
|
|
42
43
|
|
|
43
44
|
A v1 spec stays valid and carries none of these arrays. Then the requirements are `invariants`, `permissions` and `acceptanceScenarios`, and everything the spec leaves open is still a question rather than a guess.
|
|
44
45
|
|
|
@@ -16,7 +16,7 @@ The spec-element to file mapping is the table in `module-new`; the change classe
|
|
|
16
16
|
|
|
17
17
|
## 1. Read first
|
|
18
18
|
|
|
19
|
-
Read the whole module before changing it: `spec/module.yaml`, `src/index.ts`, `src/acl/permissions.ts`, `src/api/endpoints.ts`, `src/services/*`, `src/client/*`, `tests/`. Keep every exported name in `src/index.ts`, `src/server/index.ts` and `src/client/index.ts` stable: other modules import them (`modules/users` uses `AuthRuntime` from `@flowdular/sdk/modules/auth/server`), and the generated composition imports `createServerComposition` and `createClientContribution`.
|
|
19
|
+
Read the whole module before changing it: `spec/module.yaml`, `design/*.html` when the change touches a screen, `src/index.ts`, `src/acl/permissions.ts`, `src/api/endpoints.ts`, `src/services/*`, `src/client/*`, `tests/`. Keep every exported name in `src/index.ts`, `src/server/index.ts` and `src/client/index.ts` stable: other modules import them (`modules/users` uses `AuthRuntime` from `@flowdular/sdk/modules/auth/server`), and the generated composition imports `createServerComposition` and `createClientContribution`.
|
|
20
20
|
|
|
21
21
|
Sandbox facts for an edit session (`packages/sandbox/src/server/sessions.ts`): the module is copied to `workspace/modules/<dir>` and a pristine copy to `base/modules/<dir>`; the diff shown to the operator and the eject plan compare the two. The workspace is a pnpm workspace of its own (declared dependencies install for real; a `package.json` change triggers a reinstall that counts as the `dependencies` gate). The business manager updates the spec before implementation. The operator approves the exact spec hash for every affected module; editing that spec, requesting changes, or adding another module reopens its approval gate. A sandbox specialist never writes `status: approved`; a host agent may invoke approval only after an explicit current user request through `spec-approval`. Delivery checks the recorded hash again.
|
|
22
22
|
|
|
@@ -55,9 +55,11 @@ New screen or widget:
|
|
|
55
55
|
3. `src/client/index.ts`: re-export the view.
|
|
56
56
|
4. Add user-facing copy to every declared `translations/*.json` bundle and resolve it with fully qualified `t()` keys. Navigation copy uses getters because contributions exist before bundles are registered.
|
|
57
57
|
|
|
58
|
+
New feature flag: a setting with `kind: 'flag'`, `type: 'boolean'`, `scope: 'tenant'` and a `defaultValue`. It reads like any other setting, `context.settings.get<boolean>(tenantId, '<module>.core', 'key')`, and appears on the Flags tab of Administration, Modules beside the module's other flags; a change is audited as `settings.flag.changed`. Declare it in the spec as `kind: flag` too, and state in an invariant what the module does while it is off. Before changing behaviour a workspace already relies on, removing a step, or adding an outward-facing or costly path, propose a flag and let the operator decide: ship the new path behind it, default off, and keep the old path working while it is off. A flag is on or off for one workspace; there is no percentage rollout and no targeting.
|
|
59
|
+
|
|
58
60
|
New setting:
|
|
59
61
|
|
|
60
|
-
1. `src/settings.ts`: `export const X_MODULE_SETTINGS = defineModuleSettings({ moduleId: '<module>.core', settings: { key: { type: 'string' | 'number' | 'boolean', defaultValue, visibility: 'private' | 'shared', client: boolean, scope: 'tenant' | 'platform', labelKey, label, descriptionKey, description, min?, max?, enum?, secret? } } })` from `@flowdular/sdk/kernel` (`packages/kernel/src/module-settings.ts`; setting keys match `^[a-z][a-zA-Z0-9]*$`). `labelKey` and `descriptionKey` are fully qualified module translation keys present in every locale. Keep the English literals as compatibility fallbacks; values, ids and secrets are never translated.
|
|
62
|
+
1. `src/settings.ts`: `export const X_MODULE_SETTINGS = defineModuleSettings({ moduleId: '<module>.core', settings: { key: { type: 'string' | 'number' | 'boolean', kind?: 'flag', defaultValue, visibility: 'private' | 'shared', client: boolean, scope: 'tenant' | 'platform', labelKey, label, descriptionKey, description, min?, max?, enum?, secret? } } })` from `@flowdular/sdk/kernel` (`packages/kernel/src/module-settings.ts`; setting keys match `^[a-z][a-zA-Z0-9]*$`). `labelKey` and `descriptionKey` are fully qualified module translation keys present in every locale. Keep the English literals as compatibility fallbacks; values, ids and secrets are never translated.
|
|
61
63
|
2. `src/platform.ts`: return `settings: X_MODULE_SETTINGS` next to `routes`; the platform declares it at boot and Administration, Modules renders it in the module's drawer (`modules/system/src/client/ModuleSettingsSection.tsrx`, behind `system.settings.read` and `system.settings.manage`; the API is `GET /api/settings` and `POST /api/settings/update` in `modules/system/src/server/endpoints.ts`).
|
|
62
64
|
3. Read it live where it is used: `context.settings.get<number>(tenantId, '<module>.core', 'key')` at request time, never cached at boot; pass `context.settings` into the runtime or service that needs it (`modules/agents/src/settings.ts`, `agentSettings`, shows the pattern with an environment fallback).
|
|
63
65
|
4. `spec/module.yaml`: an invariant or scenario naming the setting and its bounds; `specVersion` bump. Cross-module reads of a setting need `visibility: 'shared'` and a declared dependency.
|
|
@@ -23,25 +23,26 @@ For an edit, also read the module's current `spec/module.yaml` and write the sma
|
|
|
23
23
|
|
|
24
24
|
One pass, in this order. For each row, write the default from the card into the spec and record it as a `decisions[]` entry with `decidedBy: default`. Ask only where the answer is a business fact that no default can supply.
|
|
25
25
|
|
|
26
|
-
| Decision | Default to propose
|
|
27
|
-
| ---------------------- |
|
|
28
|
-
| Actors | Owner manages, member reads
|
|
29
|
-
| Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name
|
|
30
|
-
| Uniqueness | The human-facing code is `unique: tenant`; everything else `none`
|
|
31
|
-
| States and transitions | `active` and `archived`, every transition behind the manage permission
|
|
32
|
-
| Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope
|
|
33
|
-
| What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing
|
|
34
|
-
| Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400
|
|
35
|
-
| Cross-module reads | None. A read of another module goes through its public capability and a declared dependency
|
|
36
|
-
| Screens | One `list` screen with the entity's identifying columns
|
|
37
|
-
| Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it
|
|
38
|
-
| Settings | None. A number the business may change later is `scope: tenant` with a stated default
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
26
|
+
| Decision | Default to propose | Lands in |
|
|
27
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
28
|
+
| Actors | Owner manages, member reads | `permissions`, `invariants` |
|
|
29
|
+
| Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name | `entities[]` |
|
|
30
|
+
| Uniqueness | The human-facing code is `unique: tenant`; everything else `none` | `entities[].fields[].unique` |
|
|
31
|
+
| States and transitions | `active` and `archived`, every transition behind the manage permission | `entities[].states` |
|
|
32
|
+
| Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope | `permissions`, `screens[]`, `invariants` |
|
|
33
|
+
| What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing | `acceptanceScenarios` |
|
|
34
|
+
| Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400 | `invariants`, `acceptanceScenarios` |
|
|
35
|
+
| Cross-module reads | None. A read of another module goes through its public capability and a declared dependency | `dependencies`, `dataOwnership` |
|
|
36
|
+
| Screens | One `list` screen with the entity's identifying columns | `screens[]` |
|
|
37
|
+
| Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it | `widgets[]` |
|
|
38
|
+
| Settings | None. A number the business may change later is `scope: tenant` with a stated default | `settings[]` |
|
|
39
|
+
| Feature flags | Ask for one whenever a change alters behaviour a workspace already relies on, or is hard to undo: `kind: flag`, boolean, `scope: tenant`, a stated default, and the behaviour named in an invariant | `settings[]` |
|
|
40
|
+
| Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write` | `agentTools[]` |
|
|
41
|
+
| Outside sources | None. A named public source is `research`, with the entity its findings attach to | `research` |
|
|
42
|
+
| Other systems | None. A named system is one `source` adapter per record kind, run on demand | `adapters[]` |
|
|
43
|
+
| Documents | None. A named document is one `templates[]` entry on the record it describes | `templates[]` |
|
|
44
|
+
| Reports | None. There is no export, no PDF and no search; a report is a screen or it is out of scope | `outOfScope[]` |
|
|
45
|
+
| Out of scope | Every item from the card's gap list the request touched, each with its business decision | `outOfScope[]`, `decisions[]` |
|
|
45
46
|
|
|
46
47
|
A default you propose is still a decision: it goes into `decisions[]` so the operator can see and overturn it, and so the next agent never re-derives it.
|
|
47
48
|
|
|
@@ -89,7 +90,7 @@ A number the case needs (a score, a premium, a price per square metre) is an `ac
|
|
|
89
90
|
- `screens[]`: `{ id, kind: list|record|form|dashboard, entity?, title?, columns?, filters?, navigationGroup? }`. `navigationGroup` is one of the six values on the card.
|
|
90
91
|
- `actions[]`: `{ id, entity?, permission, kind: create|update|delete|custom, risk, idempotent, description }`. `risk: external` is refused by the platform, so an action may not declare it.
|
|
91
92
|
- `widgets[]`: `{ id, slot, entity?, description }`; `slot` is one of the four workspace slots.
|
|
92
|
-
- `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, default?, values?, description }`.
|
|
93
|
+
- `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, kind?: setting|flag, default?, values?, description }`. A `flag` is boolean, `scope: tenant` and carries a default; owners switch it per workspace on the Flags tab of Administration, Modules, and every change is audited. Ask whether the new behaviour ships behind one: a change to something a workspace already does, an outward-facing action, a costly or slow path, or anything a business would want to turn off without a deployment. Say what the module does with the flag off, as an invariant.
|
|
93
94
|
- `agentTools[]`: `{ id, permission, description, risk: read|workspace-write }`.
|
|
94
95
|
- `outOfScope[]`: plain sentences, each naming the gap and the decision taken instead.
|
|
95
96
|
- `decisions[]`: `{ id, question, answer, decidedBy: user|default }`; ids match `^[A-Z][A-Z0-9-]+$`, for example `D-UNIQUE-SKU`.
|
|
@@ -93,7 +93,28 @@ Layout `ui-view`, `ui-two-col` (+`--wide-aside`), `ui-grid-2`, `ui-kpi-grid`, `u
|
|
|
93
93
|
|
|
94
94
|
User-facing copy lives in every declared `translations/*.json` bundle and is read with fully qualified `t()` keys. Eyebrow names the domain, title names the records, and description is one sentence. Table headers say what the value is. Buttons start with a verb. Loading text ends with `…`. Drawer footer states the constraint the user cannot see. Write natural copy in each locale, with no exclamation marks or database jargon.
|
|
95
95
|
|
|
96
|
-
## 7.
|
|
96
|
+
## 7. Design the screen as a preview first
|
|
97
|
+
|
|
98
|
+
A design is a file, not a description: `modules/<dir>/design/<screen>.html`, beside the spec. It is markup only, dressed by the platform's own stylesheets, so it shows what the screen will look like before a component exists and long before the application boots. An implementation phase reads it the way it reads the spec.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --scaffold # the record recipe in all five states
|
|
102
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html # renders it, prints a file:// address
|
|
103
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --shot .flowdular/ui-preview/<screen>.png
|
|
104
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --open # shows it to the person asking
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Rules that keep a preview honest:
|
|
108
|
+
|
|
109
|
+
- Markup only. No `<style>`, no CSS rule, no `<html>` or `<body>`; the command refuses a fragment that carries one. Every visual decision comes from `packages/ui`, which is what stops a preview from becoming a second source of truth.
|
|
110
|
+
- One `<section class="ui-view" data-state="...">` per state: `populated`, `loading`, `empty`, `error`, `denied`. The command labels each one, so a single page answers for all five.
|
|
111
|
+
- Real content. The longest realistic name, a real identifier, the copy the screen will actually carry. A preview of `Lorem ipsum` proves nothing about overflow or alignment.
|
|
112
|
+
- Only classes a stylesheet declares. `pnpm ui-classes:check` fails on a class nothing defines, in a preview and in a `.tsrx` alike, which is the one mistake that compiles, passes its tests and renders unstyled. A class a module needs and the design system lacks is declared in the module's own CSS, as rule 4 says.
|
|
113
|
+
- `--shot` writes a PNG, which is how an agent with no browser looks at its own work. `--open` opens the preview in the person's browser, which is how a design is shown for approval; a correction loop renders without it rather than throwing a window at whoever is at the keyboard.
|
|
114
|
+
|
|
115
|
+
Hand off the path, not a description. An implementation phase opens the preview, mirrors its structure with the components in section 4, and keeps the copy.
|
|
116
|
+
|
|
117
|
+
## 8. Inspect the rendered screen
|
|
97
118
|
|
|
98
119
|
A screen is not finished until it has been looked at. Typecheck and tests say nothing about overflow, alignment, a duplicate label or a column that collapses.
|
|
99
120
|
|
|
@@ -117,6 +138,7 @@ Check the drawer form in the same pass: one label per field, fields top-aligned,
|
|
|
117
138
|
|
|
118
139
|
## Pitfalls
|
|
119
140
|
|
|
141
|
+
- A preview that renders unstyled is a class nothing declares, not a broken harness; run `pnpm ui-classes:check`.
|
|
120
142
|
- `Kpi value={items.length}` does not typecheck; use `String(items.length)`.
|
|
121
143
|
- A `Tag` for a lifecycle state uses `success` for active and `neutral` for archived, with `dot`.
|
|
122
144
|
- An `Icon` inside `Button size="sm"` is 14, not 18.
|
|
@@ -8,7 +8,11 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
8
8
|
|
|
9
9
|
**Tenant identity.** Every request carries a principal resolved by `modules/auth`. `principalFromContext(octane)!.tenantId` is the only tenant authority; a tenant id from a body, query or header is a defect. Row-level security binds the same id inside the transaction.
|
|
10
10
|
|
|
11
|
-
**Authorization.** `defineEndpoint` (`packages/server/src/endpoint.ts`) takes `id`, `path`, `methods`, `handler` and `access`. `access` is either `{ kind: 'public' }` or `{ kind: 'permission', permission }` with `resolveIdentity: endpointIdentityFromContext`. A protected endpoint answers 401 `UNAUTHENTICATED` without an identity and 403 `FORBIDDEN` without the permission. Permission ids match `^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$` and are declared in the spec `permissions`, in `src/acl/permissions.ts` and on the endpoint. CSRF and input bounds are not endpoint options: mutations call `sessionMutationDenial(octane, auth)` first (`modules/auth/src/server/session-security.ts`) and parse the body through `readJsonObject`, `requiredString`, `optionalString`, `requiredInteger` (`packages/server/src/http.ts`, 415 without JSON, 413 over 16 KB).
|
|
11
|
+
**Authorization.** `defineEndpoint` (`packages/server/src/endpoint.ts`) takes `id`, `path`, `methods`, `handler` and `access`. `access` is either `{ kind: 'public' }` or `{ kind: 'permission', permission }` with `resolveIdentity: endpointIdentityFromContext`. A protected endpoint answers 401 `UNAUTHENTICATED` without an identity and 403 `FORBIDDEN` without the permission. Permission ids match `^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$` and are declared in the spec `permissions`, in `src/acl/permissions.ts` and on the endpoint. CSRF and input bounds are not endpoint options: mutations call `sessionMutationDenial(octane, auth)` first (`modules/auth/src/server/session-security.ts`) and parse the body through `readJsonObject`, `requiredString`, `optionalString`, `requiredInteger` (`packages/server/src/http.ts`, 415 without JSON, 413 over 16 KB). That same guard is what admits a machine caller: it passes a browser session with a same origin and a valid CSRF token, or an API token the workspace issued with writes allowed, and answers 403 `TOKEN_MUTATION_DENIED` to a token issued without them. A mutation that shapes who may reach the workspace calls `browserSessionMutationDenial` instead, which refuses every token; auth.core's own routes are the ones that do.
|
|
12
|
+
|
|
13
|
+
**External API.** Every endpoint records itself in the process endpoint catalogue as it is defined (`packages/server/src/endpoint-catalog.ts`), so a module is described without declaring anything twice. `defineEndpoint` takes an optional `documentation` with `summary`, `description`, `parameters`, `body` and `responses`, validated at definition time and bounded (160-character summary, 32 parameters, 16 responses, 16 KB per JSON Schema); an endpoint that declares none is still described by its address, its methods and the permission it demands. `GET /api/openapi.json` (`platform/src/server/openapi.ts`) answers OpenAPI 3.1 built for the presented credential: it needs a browser session or an API token, and leaves out every operation whose permission the caller does not hold and every operation of a module inactive in its workspace. Each operation carries `x-flowdular-module`, `x-flowdular-permission` and `x-flowdular-token-write`. The Administration screen `system.core` API lists the same document. A module adds nothing to be included; it is in the document as soon as it composes.
|
|
14
|
+
|
|
15
|
+
**API tokens and cross-origin access.** A workspace owner issues a token in Administration, API tokens, with the scopes it may use, an optional expiry, whether it may write, and the browser origins it may be presented from (`modules/auth/src/server/token-endpoints.ts`). Its authority is the intersection of its recorded scopes with the live membership at every request. Writes are off unless the owner asked; a token that names no origin is refused from every browser origin and is meant for a server-side caller; a token that names some is refused from anywhere else with 403 `TOKEN_ORIGIN_DENIED`. A cross-origin preflight carries no credential, so `createCorsMiddleware` (`packages/server/src/cors.ts`, composed in `platform/octane.config.ts`) answers it from the union of the origins the deployment's live tokens declare, and never sends `access-control-allow-credentials`, so no cross-origin page can read an API response with the dashboard's session cookie. A token also carries the requests per minute it may spend, and one issued with none spends the platform setting `auth.core.apiTokenRateLimit` (default 600, 0 removes the ceiling); the request over it is answered 429 `TOKEN_RATE_LIMITED` with `retry-after`, every answer to a limited token carries `x-ratelimit-limit`, `-remaining` and `-reset`, and every request a token made, refused ones included, plus the refused subset, are counted as auth.core module metrics on `/api/metrics`. The window is a fixed minute per token in each process (`modules/auth/src/middleware/rate-limit.ts`), bounded to 4096 credentials, so it guards against one integration running away rather than accounting for usage. Nothing here is an environment variable; issuing or revoking a token, or changing the setting, takes effect live.
|
|
12
16
|
|
|
13
17
|
**Record policies.** A permission answers who may act on a kind of record; a policy answers whether this record allows it. `definePolicy` (`packages/kernel/src/acl.ts`) declares `{ id, permission, evaluate }`, where `evaluate({ principal, permission, record, action })` returns `{ allowed: true }` or `{ allowed: false, reason, requiresApproval? }`; `requiresApproval.requirement` carries `roleKey`, `scope`, `decisions` and `expiresInDays`, and nothing more: resolving a role to people, collecting their decisions and keeping the receipt belong to `approvals.core`, described below. Policies are registered into a `createPolicyRegistry()` while the module composes, at most 256, one registration per id, and the registry is sealed before requests run. An endpoint calls `authorizeRecord(registry, principal, permission, record, action?)`, which checks the permission scope first and then the policies of that permission in registration order, answering the first denial. A permission with no policy is allowed by the scope alone, and a policy that throws denies. The platform does not hand a module a shared registry yet, so a module that wants record conditions today owns its registry inside its own composition.
|
|
14
18
|
|
|
@@ -22,7 +26,7 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
22
26
|
|
|
23
27
|
**Module settings.** `defineModuleSettings` (`packages/kernel/src/module-settings.ts`) declares `{ moduleId, settings }`. A setting has `type: 'string' | 'number' | 'boolean'`, `defaultValue`, `visibility: 'private' | 'shared'`, `client: boolean`, and optionally `kind: 'flag'`, `scope: 'platform' | 'tenant'`, `secret`, `labelKey`, `descriptionKey`, `label`, `description`, `enum` (string type only), `min`, `max`, `pattern`, `multiline`. Keys match `^[a-z][a-zA-Z0-9]*$`. The allowed-values field is `enum`, not `values`. A `secret` setting can be neither `client: true` nor `visibility: 'shared'`. Values are read live with `context.settings.get(tenantId, moduleId, key)` and edited in Administration, Modules behind `system.settings.read` and `system.settings.manage` (`GET /api/settings`, `POST /api/settings/update`, `modules/system/src/server/endpoints.ts`). A read needs the tenant primed first (`await context.settings.prime(tenantId)`): the authenticated request path and the platform composition prime, a background path primes itself, and `set` is awaited.
|
|
24
28
|
|
|
25
|
-
**Feature flags.** A flag is a module setting declared `kind: 'flag'`: a non-secret `boolean` with a `defaultValue`, a `label`, a `description` and `scope: 'tenant'`, which is the default and the only scope a flag may take; `defineModuleSettings` refuses anything else, a platform-scoped flag included. There is no flag store, no flag endpoint and no flag registry. A module reads one on the request path with the ordinary settings read, `context.settings.get<boolean>(tenantId, moduleId, key)`: a lookup of the declaration, a lookup of the cached per `(tenant, module)` value set under a key the read builds, the touch that keeps that entry at the head of the cache, the property read and one `typeof` check. No query of its own, and nothing beyond what any setting already costs: a declared `pattern` is compiled once with the declaration, never per read. There is deliberately no `context.flags`; the read is the settings read, and the module id is the one the module already knows. An override is per workspace behind `system.settings.manage` on the Flags tab of Administration, Modules, which groups every declared flag by its owning module and links the audit trail. Every change appends one `settings.flag.changed` auth audit event with the module, the key, the previous and next values and the actor (`settings.updated` stays the event for every other setting). No percentage rollout and no targeting: a flag is on or off for a workspace.
|
|
29
|
+
**Feature flags.** A flag is a module setting declared `kind: 'flag'`: a non-secret `boolean` with a `defaultValue`, a `label`, a `description` and `scope: 'tenant'`, which is the default and the only scope a flag may take; `defineModuleSettings` refuses anything else, a platform-scoped flag included. There is no flag store, no flag endpoint and no flag registry. A module reads one on the request path with the ordinary settings read, `context.settings.get<boolean>(tenantId, moduleId, key)`: a lookup of the declaration, a lookup of the cached per `(tenant, module)` value set under a key the read builds, the touch that keeps that entry at the head of the cache, the property read and one `typeof` check. No query of its own, and nothing beyond what any setting already costs: a declared `pattern` is compiled once with the declaration, never per read. There is deliberately no `context.flags`; the read is the settings read, and the module id is the one the module already knows. An override is per workspace behind `system.settings.manage` on the Flags tab of Administration, Modules, which groups every declared flag by its owning module and links the audit trail. Every change appends one `settings.flag.changed` auth audit event with the module, the key, the previous and next values and the actor (`settings.updated` stays the event for every other setting). No percentage rollout and no targeting: a flag is on or off for a workspace. A specification declares one as a `settings[]` entry with `kind: flag`, which `spec validate` holds to boolean, `scope: tenant` and a stated default; `research.core` ships the first one, `allowAgents`.
|
|
26
30
|
|
|
27
31
|
**Module activation.** The composed module set is CLI-owned and baked at build; what an owner changes from Administration, Modules is per-workspace activation of the modules the application already composes. `system.core` keeps it in `system_module_activations` (a composed module without a row is active), lists it through `GET /api/system/modules` (every catalog row with `active`, `optional` and `dependents`) and `GET /api/system/modules/active` (the active composed ids, for any member holding `system.workspace.access`), and changes it through `POST /api/system/modules/activate` and `/api/system/modules/deactivate` behind `system.settings.manage` with CSRF first. `system.core`, `auth.core`, `users.core` and `profile.core` (`REQUIRED_MODULE_IDS` in `@flowdular/sdk/contracts`) are never deactivated; a module another active module depends on, through a declared module dependency or a required capability, is refused with 409 `MODULE_HAS_ACTIVE_DEPENDENTS` naming the dependents, a required one with 409 `MODULE_REQUIRED`, and activating a module whose dependency is inactive with 409 `MODULE_DEPENDENCY_INACTIVE`. Every change appends one `system.module.activated` or `system.module.deactivated` auth audit event. The state is one per-tenant snapshot memoised for 30 seconds and published as the public capability `system.modules.v1` (`isActive(tenantId, moduleId)`, `activeIds(tenantId)`, `modules/system/src/server/capability.ts`). Enforcement costs a module nothing: the generated composition binds every route to its module id (`bindModuleCompositions`, `packages/server/src/module-activation.ts`), `defineEndpoint` answers 403 `MODULE_INACTIVE` after the permission check for an endpoint of an inactive module in the principal's workspace (`EndpointIdentity.tenantId` comes from `endpointIdentityFromContext`), and the application shell reads the active ids before it renders and hides the navigation, views, widgets and command search of an inactive module (`contributionsForActiveModules`, `packages/client/src/shell/modules.ts`; a failed read shows everything). Not covered yet: the agent tools of an inactive module are still offered, because the harness registry has no per-tenant module hook.
|
|
28
32
|
|
|
@@ -56,13 +60,13 @@ Read this file before writing or implementing a spec. It replaces scanning `modu
|
|
|
56
60
|
|
|
57
61
|
**Translations.** The shipped locales are `en` and `pl`. A module keeps a flat bundle per locale in `translations/<locale>.json`, imports them in `src/client/contribution.tsrx` and returns `translations: { en, pl }`. Keys resolve fully qualified as `<first module segment>.<key>` through `t()`. A count is a plural family (`<key>.one`/`.other` in `en`, `<key>.one`/`.few`/`.many`/`.other` in `pl`) read as `t(key, { count })`. `flowdular module validate` reports `TRANSLATION_FILE_MISSING`, `TRANSLATION_PARSE_ERROR`, `TRANSLATION_KEYS_MISMATCH`, `TRANSLATION_PLURAL_INCOMPLETE` and `TRANSLATION_KEY_MISSING`.
|
|
58
62
|
|
|
59
|
-
**Web surfaces.** A module may own public, server-rendered website pages separate from the authenticated shell (`packages/server/src/web.ts`). `defineWebSurface` declares `{ id, pages }` with at most 128 pages; a page has `id`, `path`, `entry`, optional `layout`, a `load` function and `access`, which is one of `{ kind: 'public' }`, `{ kind: 'authenticated' }` or `{ kind: 'permission', permission }`. The operator, not the module, mounts a surface at a tenant-scoped path; `setup`, `health`, `ready`, `assets`, `app`, `api`, `auth`, `sign-in`, `sign-up`, `forgot-password`, `reset-password` and `accept-invitation` are reserved.
|
|
63
|
+
**Web surfaces.** A module may own public, server-rendered website pages separate from the authenticated shell (`packages/server/src/web.ts`). `defineWebSurface` declares `{ id, pages }` with at most 128 pages; a page has `id`, `path`, `entry`, optional `layout`, a `load` function and `access`, which is one of `{ kind: 'public' }`, `{ kind: 'authenticated' }` or `{ kind: 'permission', permission }`. The operator, not the module, mounts a surface at a tenant-scoped path with `pnpm flowdular web mount <module id> <surface id> --path <path> --tenant <id> --apply`, which writes `flowdular.json` and regenerates the composition (`web list` and `web unmount <mount id>` are its siblings); `setup`, `health`, `ready`, `assets`, `app`, `api`, `auth`, `sign-in`, `sign-up`, `forgot-password`, `reset-password` and `accept-invitation` are reserved (`RESERVED_WEB_SEGMENTS` in `@flowdular/sdk/contracts`), and a mount naming a surface its module does not declare stops the composition rather than answering 404.
|
|
60
64
|
|
|
61
65
|
**Public capabilities registry.** Cross-module access goes through `context.capabilities.register(id, value)` in the owning module and `context.capabilities.get<T>(id)` in the consumer (`packages/kernel/src/capability-registry.ts`). The owning module lists the id under `provides` in `module.json`; the consumer lists it under `requires` (`{ "id", "optional": true }` when it handles a null `get`). The composition gives each module a registry view that accepts only its declared ids, the kernel orders required providers first and refuses a missing provider, and the installer resolves a required capability to a release that provides it. A type import from the provider's package still needs the module and package dependency (`"range": "^0.11.0"`). A module never opens another module's database or imports its `src/` path.
|
|
62
66
|
|
|
63
67
|
**Notifications.** `notifications.core` (optional, enabled by default) owns a per-member in-app inbox with preferences, per-tenant outbound webhook subscriptions signed with the inbound automations scheme, and e-mail delivery of inbox items, all three sharing one delivery ledger with bounded retry and a dead letter. A module publishes through the public capability `notifications.publish.v1` obtained lazily with `context.capabilities.get` and tolerates its absence; the input is `{ tenantId, kind, sourceModule, sourceRef, title, body?, recipients }` with the kinds `agent-run-completed`, `agent-run-failed`, `workflow-run-completed`, `workflow-run-failed`, `webhook-dead-letter` (`modules/notifications/src/domain/publish.ts`). A new kind is a `notifications.core` spec edit, not a publisher's decision. A publisher never chooses a channel: the member does, with the per-kind switch that decides whether an item exists at all and the `emailDelivery` switch (off by default) that also mails the items they receive, through the platform mail port and the address `auth.core` holds. `ToastHost` and `toasts` remain the in-screen confirmation for what the reader just did.
|
|
64
68
|
|
|
65
|
-
**Mail.** A module sends through `context.mail` (`packages/server/src/mail/`) and never selects a transport, a relay or a provider SDK. `send({ to, subject, text, html?, locale?, headers? })` takes at most 16 recipients, a 200 character single-line subject, 64 KB of text, 256 KB of HTML and 16 extra headers whose names the envelope does not own; CR and LF are refused everywhere a header could be opened, so header injection is the port's problem and not each sender's. It rejects with a `MailError` carrying `MAIL_NOT_CONFIGURED` (no transport composed), `MAIL_MESSAGE_REJECTED` (a bound) or `MAIL_DELIVERY_FAILED` (the relay, whose words never travel with it); `mail.configured` is the flag a feature gates on instead of provoking a refusal. `renderMailTemplate({ subject, text, html? }, values)` fills `{{ name }}` holes in one pass and escapes every value for the HTML part. The deployment picks the adapter with `FD_MAIL_TRANSPORT`: `none` (refuses), `development` (an in-memory outbox of the last 100 messages, refused in production) or `smtp` (`FD_MAIL_SMTP_URL`, `FD_MAIL_FROM`; the retired `FD_AUTH_MAIL_*` names still work with a deprecation line). `auth.core` sends invitations, resets and confirmations through it, `notifications.core` mails inbox items; a module that wants to reach a person publishes a notification rather than composing mail of its own.
|
|
69
|
+
**Mail.** A module sends through `context.mail` (`packages/server/src/mail/`) and never selects a transport, a relay or a provider SDK. `send({ to, subject, text, html?, locale?, headers? })` takes at most 16 recipients, a 200 character single-line subject, 64 KB of text, 256 KB of HTML and 16 extra headers whose names the envelope does not own; CR and LF are refused everywhere a header could be opened, so header injection is the port's problem and not each sender's. It rejects with a `MailError` carrying `MAIL_NOT_CONFIGURED` (no transport composed), `MAIL_MESSAGE_REJECTED` (a bound) or `MAIL_DELIVERY_FAILED` (the relay, whose words never travel with it); `mail.configured` is the flag a feature gates on instead of provoking a refusal. `renderMailTemplate({ subject, text, html? }, values)` fills `{{ name }}` holes in one pass and escapes every value for the HTML part. The deployment picks the adapter with `FD_MAIL_TRANSPORT`: `none` (refuses), `development` (an in-memory outbox of the last 100 messages, refused in production) or `smtp` (`FD_MAIL_SMTP_URL`, `FD_MAIL_FROM`; the retired `FD_AUTH_MAIL_*` names still work with a deprecation line). An installation may instead store the relay in the platform-scoped `auth.core` settings `mailTransport`, `mailSmtpUrl` (secret), `mailFrom`, `mailRequireTls` and `mailRejectUnauthorized`; a stored transport of `none` or `smtp` wins over the environment for every sender and is resolved per message, while the default `environment` leaves `FD_MAIL_*` in effect and is the only way to reach the development adapter. `auth.core` sends invitations, resets and confirmations through it, `notifications.core` mails inbox items; a module that wants to reach a person publishes a notification rather than composing mail of its own.
|
|
66
70
|
|
|
67
71
|
**Object storage.** A module writes files through `context.storage` (`packages/storage/src/index.ts`) and never sees an adapter, a bucket or a path. `put`, `get`, `delete`, `stat` and `readUrl` take `{ tenantId, moduleId, objectId }`; the key is `<tenantId>/<moduleId>/<objectId>` and the tenant id comes from the principal, never from the request, because no row-level security reaches an object store. Development and test use a local directory, a deployment uses an S3-compatible bucket (`FD_STORAGE_ADAPTER=local|s3`, `local` refused in production). Every object is encrypted with AES-256-GCM under `FD_STORAGE_ENCRYPTION_KEY` before it is written, with the key id and the metadata authenticated alongside it. An object is at most 25 MB (`FD_STORAGE_MAX_OBJECT_BYTES`) and must be one of PDF, PNG, JPEG, GIF, WebP, plain text, CSV, `.docx`, `.xlsx`, `.pptx`, `application/msword` or `application/vnd.ms-excel`, verified against the bytes; archives and executables are refused. A malware scanner is a deployment seam, so the stored verdict is `clean`, `infected` (refused) or `unscanned` (the default). `readUrl` returns `/api/storage/objects/<token>`, a signed platform route that expires in at most an hour and streams the decrypted body as an attachment, never a presigned URL to the ciphertext. Upload, metadata and the attachment table belong to `documents.core`, described next; a module never puts bytes of its own through `context.storage` when a document fits.
|
|
68
72
|
|
|
@@ -104,6 +104,7 @@ commandsWithoutDescriptor:
|
|
|
104
104
|
- pnpm flowdular capability list|describe <id>|run <id>
|
|
105
105
|
- pnpm flowdular blueprint list
|
|
106
106
|
- pnpm flowdular module list
|
|
107
|
+
- pnpm flowdular web list
|
|
107
108
|
- pnpm flowdular module sync [--apply]
|
|
108
109
|
- pnpm flowdular module enable <id> [--apply] (with --apply also runs auth.scopes.sync; result field scopes, failure code MODULE_SCOPES_SYNC_FAILED)
|
|
109
110
|
- pnpm flowdular module disable <id> [--apply]
|
|
@@ -26,25 +26,26 @@ Every `acceptanceScenarios[]` entry maps to at least one test in `tests/`. A sce
|
|
|
26
26
|
|
|
27
27
|
Each spec element maps to files:
|
|
28
28
|
|
|
29
|
-
| Spec element | Files it produces
|
|
30
|
-
| ----------------------------- |
|
|
31
|
-
| `entities[]` | `src/domain/types.ts`, `migrations/000N_<module>_<name>.{up,down}.sql`, `src/services/migration.ts`, `src/services/{repository,database-repository}.ts`
|
|
32
|
-
| `entities[].fields[]` | the columns and row mapping above, the validation bounds in `src/api/endpoints.ts`, the form field and table cell in `src/client/*.tsrx`
|
|
33
|
-
| `fields[].unique: tenant` | a `(tenant_id, <field>)` unique index in the migration plus the stable conflict code in the service
|
|
34
|
-
| `entities[].states` | the status column, the transition guard in the service, the `Tag` tone in the view
|
|
35
|
-
| `screens[]` | `src/client/<Pascal>View.tsrx`, a `views` entry and a `navigation` entry in `src/client/contribution.tsrx`, `translations/*.json`
|
|
36
|
-
| `
|
|
37
|
-
| `screens[].
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
29
|
+
| Spec element | Files it produces |
|
|
30
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `entities[]` | `src/domain/types.ts`, `migrations/000N_<module>_<name>.{up,down}.sql`, `src/services/migration.ts`, `src/services/{repository,database-repository}.ts` |
|
|
32
|
+
| `entities[].fields[]` | the columns and row mapping above, the validation bounds in `src/api/endpoints.ts`, the form field and table cell in `src/client/*.tsrx` |
|
|
33
|
+
| `fields[].unique: tenant` | a `(tenant_id, <field>)` unique index in the migration plus the stable conflict code in the service |
|
|
34
|
+
| `entities[].states` | the status column, the transition guard in the service, the `Tag` tone in the view |
|
|
35
|
+
| `screens[]` | `src/client/<Pascal>View.tsrx`, a `views` entry and a `navigation` entry in `src/client/contribution.tsrx`, `translations/*.json` |
|
|
36
|
+
| `design/<screen>.html` | the approved look of that screen: mirror its structure and keep its copy. Render it with `pnpm ui:preview <path>`; absent, design it first with `ux-design` |
|
|
37
|
+
| `screens[].columns`/`filters` | the `TableColumn[]` outside the component, and the controls inside the `Filters` dropdown |
|
|
38
|
+
| `screens[].navigationGroup` | the navigation entry's `group` |
|
|
39
|
+
| `actions[]` | the service method with its error code, the `defineEndpoint` route, the fetch in `src/client/api.ts` |
|
|
40
|
+
| `widgets[]` | a widget component plus a `widgets` entry with its `slot` in `src/client/contribution.tsrx` |
|
|
41
|
+
| `settings[]` | `src/settings.ts` (`defineModuleSettings`) and `settings:` in `src/platform.ts`; a `kind: flag` entry keeps `kind: 'flag'` there, so owners switch it on the Flags tab of Administration, Modules |
|
|
42
|
+
| `agentTools[]` | `src/agent/tools.ts` and the `context.agentTools.register` call, as a separate `agent-tool-design` phase |
|
|
43
|
+
| `research` | `src/research.ts`, `research-fixtures.json`, `requires` of the capabilities, the evidence attach where the `evidenceOwner` record is written |
|
|
44
|
+
| `adapters[]` | `src/adapters/<name>.ts`, the port, the adapter registration, `adapters/<name>.recorded.json`, as a separate `integration-adapter` phase |
|
|
45
|
+
| `templates[]` | `templates/<name>.md`, `templates` in `package.json` `files`, `src/templates.ts` registered through `documents.templates.v1` in `src/platform.ts` |
|
|
46
|
+
| `permissions[]` | `src/acl/permissions.ts`, the endpoint `access.permission`, the client `scope` |
|
|
47
|
+
| `acceptanceScenarios[]` | `tests/module.test.ts` and its siblings, at least one case each |
|
|
48
|
+
| `outOfScope[]`, `decisions[]` | no code. Read them so you do not rebuild a decision or implement a deferred feature. |
|
|
48
49
|
|
|
49
50
|
A v1 spec stays valid and carries none of these arrays. Then the requirements are `invariants`, `permissions` and `acceptanceScenarios`, and everything the spec leaves open is still a question rather than a guess.
|
|
50
51
|
|
|
@@ -23,7 +23,7 @@ The spec-element to file mapping is the table in `module-new`; the change classe
|
|
|
23
23
|
|
|
24
24
|
## 1. Read first
|
|
25
25
|
|
|
26
|
-
Read the whole module before changing it: `spec/module.yaml`, `src/index.ts`, `src/acl/permissions.ts`, `src/api/endpoints.ts`, `src/services/*`, `src/client/*`, `tests/`. Keep every exported name in `src/index.ts`, `src/server/index.ts` and `src/client/index.ts` stable: other modules import them (`modules/users` uses `AuthRuntime` from `@flowdular/sdk/modules/auth/server`), and the generated composition imports `createServerComposition` and `createClientContribution`.
|
|
26
|
+
Read the whole module before changing it: `spec/module.yaml`, `design/*.html` when the change touches a screen, `src/index.ts`, `src/acl/permissions.ts`, `src/api/endpoints.ts`, `src/services/*`, `src/client/*`, `tests/`. Keep every exported name in `src/index.ts`, `src/server/index.ts` and `src/client/index.ts` stable: other modules import them (`modules/users` uses `AuthRuntime` from `@flowdular/sdk/modules/auth/server`), and the generated composition imports `createServerComposition` and `createClientContribution`.
|
|
27
27
|
|
|
28
28
|
Sandbox facts for an edit session (`packages/sandbox/src/server/sessions.ts`): the module is copied to `workspace/modules/<dir>` and a pristine copy to `base/modules/<dir>`; the diff shown to the operator and the eject plan compare the two. The workspace is a pnpm workspace of its own (declared dependencies install for real; a `package.json` change triggers a reinstall that counts as the `dependencies` gate). The business manager updates the spec before implementation. The operator approves the exact spec hash for every affected module; editing that spec, requesting changes, or adding another module reopens its approval gate. A sandbox specialist never writes `status: approved`; a host agent may invoke approval only after an explicit current user request through `spec-approval`. Delivery checks the recorded hash again.
|
|
29
29
|
|
|
@@ -62,9 +62,11 @@ New screen or widget:
|
|
|
62
62
|
3. `src/client/index.ts`: re-export the view.
|
|
63
63
|
4. Add user-facing copy to every declared `translations/*.json` bundle and resolve it with fully qualified `t()` keys. Navigation copy uses getters because contributions exist before bundles are registered.
|
|
64
64
|
|
|
65
|
+
New feature flag: a setting with `kind: 'flag'`, `type: 'boolean'`, `scope: 'tenant'` and a `defaultValue`. It reads like any other setting, `context.settings.get<boolean>(tenantId, '<module>.core', 'key')`, and appears on the Flags tab of Administration, Modules beside the module's other flags; a change is audited as `settings.flag.changed`. Declare it in the spec as `kind: flag` too, and state in an invariant what the module does while it is off. Before changing behaviour a workspace already relies on, removing a step, or adding an outward-facing or costly path, propose a flag and let the operator decide: ship the new path behind it, default off, and keep the old path working while it is off. A flag is on or off for one workspace; there is no percentage rollout and no targeting.
|
|
66
|
+
|
|
65
67
|
New setting:
|
|
66
68
|
|
|
67
|
-
1. `src/settings.ts`: `export const X_MODULE_SETTINGS = defineModuleSettings({ moduleId: '<module>.core', settings: { key: { type: 'string' | 'number' | 'boolean', defaultValue, visibility: 'private' | 'shared', client: boolean, scope: 'tenant' | 'platform', labelKey, label, descriptionKey, description, min?, max?, enum?, secret? } } })` from `@flowdular/sdk/kernel` (`packages/kernel/src/module-settings.ts`; setting keys match `^[a-z][a-zA-Z0-9]*$`). `labelKey` and `descriptionKey` are fully qualified module translation keys present in every locale. Keep the English literals as compatibility fallbacks; values, ids and secrets are never translated.
|
|
69
|
+
1. `src/settings.ts`: `export const X_MODULE_SETTINGS = defineModuleSettings({ moduleId: '<module>.core', settings: { key: { type: 'string' | 'number' | 'boolean', kind?: 'flag', defaultValue, visibility: 'private' | 'shared', client: boolean, scope: 'tenant' | 'platform', labelKey, label, descriptionKey, description, min?, max?, enum?, secret? } } })` from `@flowdular/sdk/kernel` (`packages/kernel/src/module-settings.ts`; setting keys match `^[a-z][a-zA-Z0-9]*$`). `labelKey` and `descriptionKey` are fully qualified module translation keys present in every locale. Keep the English literals as compatibility fallbacks; values, ids and secrets are never translated.
|
|
68
70
|
2. `src/platform.ts`: return `settings: X_MODULE_SETTINGS` next to `routes`; the platform declares it at boot and Administration, Modules renders it in the module's drawer (`modules/system/src/client/ModuleSettingsSection.tsrx`, behind `system.settings.read` and `system.settings.manage`; the API is `GET /api/settings` and `POST /api/settings/update` in `modules/system/src/server/endpoints.ts`).
|
|
69
71
|
3. Read it live where it is used: `context.settings.get<number>(tenantId, '<module>.core', 'key')` at request time, never cached at boot; pass `context.settings` into the runtime or service that needs it (`modules/agents/src/settings.ts`, `agentSettings`, shows the pattern with an environment fallback).
|
|
70
72
|
4. `spec/module.yaml`: an invariant or scenario naming the setting and its bounds; `specVersion` bump. Cross-module reads of a setting need `visibility: 'shared'` and a declared dependency.
|
|
@@ -29,25 +29,26 @@ For an edit, also read the module's current `spec/module.yaml` and write the sma
|
|
|
29
29
|
|
|
30
30
|
One pass, in this order. For each row, write the default from the card into the spec and record it as a `decisions[]` entry with `decidedBy: default`. Ask only where the answer is a business fact that no default can supply.
|
|
31
31
|
|
|
32
|
-
| Decision | Default to propose
|
|
33
|
-
| ---------------------- |
|
|
34
|
-
| Actors | Owner manages, member reads
|
|
35
|
-
| Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name
|
|
36
|
-
| Uniqueness | The human-facing code is `unique: tenant`; everything else `none`
|
|
37
|
-
| States and transitions | `active` and `archived`, every transition behind the manage permission
|
|
38
|
-
| Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope
|
|
39
|
-
| What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing
|
|
40
|
-
| Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400
|
|
41
|
-
| Cross-module reads | None. A read of another module goes through its public capability and a declared dependency
|
|
42
|
-
| Screens | One `list` screen with the entity's identifying columns
|
|
43
|
-
| Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it
|
|
44
|
-
| Settings | None. A number the business may change later is `scope: tenant` with a stated default
|
|
45
|
-
|
|
|
46
|
-
|
|
|
47
|
-
|
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
32
|
+
| Decision | Default to propose | Lands in |
|
|
33
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
34
|
+
| Actors | Owner manages, member reads | `permissions`, `invariants` |
|
|
35
|
+
| Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name | `entities[]` |
|
|
36
|
+
| Uniqueness | The human-facing code is `unique: tenant`; everything else `none` | `entities[].fields[].unique` |
|
|
37
|
+
| States and transitions | `active` and `archived`, every transition behind the manage permission | `entities[].states` |
|
|
38
|
+
| Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope | `permissions`, `screens[]`, `invariants` |
|
|
39
|
+
| What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing | `acceptanceScenarios` |
|
|
40
|
+
| Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400 | `invariants`, `acceptanceScenarios` |
|
|
41
|
+
| Cross-module reads | None. A read of another module goes through its public capability and a declared dependency | `dependencies`, `dataOwnership` |
|
|
42
|
+
| Screens | One `list` screen with the entity's identifying columns | `screens[]` |
|
|
43
|
+
| Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it | `widgets[]` |
|
|
44
|
+
| Settings | None. A number the business may change later is `scope: tenant` with a stated default | `settings[]` |
|
|
45
|
+
| Feature flags | Ask for one whenever a change alters behaviour a workspace already relies on, or is hard to undo: `kind: flag`, boolean, `scope: tenant`, a stated default, and the behaviour named in an invariant | `settings[]` |
|
|
46
|
+
| Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write` | `agentTools[]` |
|
|
47
|
+
| Outside sources | None. A named public source is `research`, with the entity its findings attach to | `research` |
|
|
48
|
+
| Other systems | None. A named system is one `source` adapter per record kind, run on demand | `adapters[]` |
|
|
49
|
+
| Documents | None. A named document is one `templates[]` entry on the record it describes | `templates[]` |
|
|
50
|
+
| Reports | None. There is no export, no PDF and no search; a report is a screen or it is out of scope | `outOfScope[]` |
|
|
51
|
+
| Out of scope | Every item from the card's gap list the request touched, each with its business decision | `outOfScope[]`, `decisions[]` |
|
|
51
52
|
|
|
52
53
|
A default you propose is still a decision: it goes into `decisions[]` so the operator can see and overturn it, and so the next agent never re-derives it.
|
|
53
54
|
|
|
@@ -95,7 +96,7 @@ A number the case needs (a score, a premium, a price per square metre) is an `ac
|
|
|
95
96
|
- `screens[]`: `{ id, kind: list|record|form|dashboard, entity?, title?, columns?, filters?, navigationGroup? }`. `navigationGroup` is one of the six values on the card.
|
|
96
97
|
- `actions[]`: `{ id, entity?, permission, kind: create|update|delete|custom, risk, idempotent, description }`. `risk: external` is refused by the platform, so an action may not declare it.
|
|
97
98
|
- `widgets[]`: `{ id, slot, entity?, description }`; `slot` is one of the four workspace slots.
|
|
98
|
-
- `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, default?, values?, description }`.
|
|
99
|
+
- `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, kind?: setting|flag, default?, values?, description }`. A `flag` is boolean, `scope: tenant` and carries a default; owners switch it per workspace on the Flags tab of Administration, Modules, and every change is audited. Ask whether the new behaviour ships behind one: a change to something a workspace already does, an outward-facing action, a costly or slow path, or anything a business would want to turn off without a deployment. Say what the module does with the flag off, as an invariant.
|
|
99
100
|
- `agentTools[]`: `{ id, permission, description, risk: read|workspace-write }`.
|
|
100
101
|
- `outOfScope[]`: plain sentences, each naming the gap and the decision taken instead.
|
|
101
102
|
- `decisions[]`: `{ id, question, answer, decidedBy: user|default }`; ids match `^[A-Z][A-Z0-9-]+$`, for example `D-UNIQUE-SKU`.
|
|
@@ -98,7 +98,28 @@ Layout `ui-view`, `ui-two-col` (+`--wide-aside`), `ui-grid-2`, `ui-kpi-grid`, `u
|
|
|
98
98
|
|
|
99
99
|
User-facing copy lives in every declared `translations/*.json` bundle and is read with fully qualified `t()` keys. Eyebrow names the domain, title names the records, and description is one sentence. Table headers say what the value is. Buttons start with a verb. Loading text ends with `…`. Drawer footer states the constraint the user cannot see. Write natural copy in each locale, with no exclamation marks or database jargon.
|
|
100
100
|
|
|
101
|
-
## 7.
|
|
101
|
+
## 7. Design the screen as a preview first
|
|
102
|
+
|
|
103
|
+
A design is a file, not a description: `modules/<dir>/design/<screen>.html`, beside the spec. It is markup only, dressed by the platform's own stylesheets, so it shows what the screen will look like before a component exists and long before the application boots. An implementation phase reads it the way it reads the spec.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --scaffold # the record recipe in all five states
|
|
107
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html # renders it, prints a file:// address
|
|
108
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --shot .flowdular/ui-preview/<screen>.png
|
|
109
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --open # shows it to the person asking
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Rules that keep a preview honest:
|
|
113
|
+
|
|
114
|
+
- Markup only. No `<style>`, no CSS rule, no `<html>` or `<body>`; the command refuses a fragment that carries one. Every visual decision comes from `packages/ui`, which is what stops a preview from becoming a second source of truth.
|
|
115
|
+
- One `<section class="ui-view" data-state="...">` per state: `populated`, `loading`, `empty`, `error`, `denied`. The command labels each one, so a single page answers for all five.
|
|
116
|
+
- Real content. The longest realistic name, a real identifier, the copy the screen will actually carry. A preview of `Lorem ipsum` proves nothing about overflow or alignment.
|
|
117
|
+
- Only classes a stylesheet declares. `pnpm ui-classes:check` fails on a class nothing defines, in a preview and in a `.tsrx` alike, which is the one mistake that compiles, passes its tests and renders unstyled. A class a module needs and the design system lacks is declared in the module's own CSS, as rule 4 says.
|
|
118
|
+
- `--shot` writes a PNG, which is how an agent with no browser looks at its own work. `--open` opens the preview in the person's browser, which is how a design is shown for approval; a correction loop renders without it rather than throwing a window at whoever is at the keyboard.
|
|
119
|
+
|
|
120
|
+
Hand off the path, not a description. An implementation phase opens the preview, mirrors its structure with the components in section 4, and keeps the copy.
|
|
121
|
+
|
|
122
|
+
## 8. Inspect the rendered screen
|
|
102
123
|
|
|
103
124
|
A screen is not finished until it has been looked at. Typecheck and tests say nothing about overflow, alignment, a duplicate label or a column that collapses.
|
|
104
125
|
|
|
@@ -122,6 +143,7 @@ Check the drawer form in the same pass: one label per field, fields top-aligned,
|
|
|
122
143
|
|
|
123
144
|
## Pitfalls
|
|
124
145
|
|
|
146
|
+
- A preview that renders unstyled is a class nothing declares, not a broken harness; run `pnpm ui-classes:check`.
|
|
125
147
|
- `Kpi value={items.length}` does not typecheck; use `String(items.length)`.
|
|
126
148
|
- A `Tag` for a lifecycle state uses `success` for active and `neutral` for archived, with `dot`.
|
|
127
149
|
- An `Icon` inside `Button size="sm"` is 14, not 18.
|
|
@@ -20,25 +20,26 @@ Every `acceptanceScenarios[]` entry maps to at least one test in `tests/`. A sce
|
|
|
20
20
|
|
|
21
21
|
Each spec element maps to files:
|
|
22
22
|
|
|
23
|
-
| Spec element | Files it produces
|
|
24
|
-
| ----------------------------- |
|
|
25
|
-
| `entities[]` | `src/domain/types.ts`, `migrations/000N_<module>_<name>.{up,down}.sql`, `src/services/migration.ts`, `src/services/{repository,database-repository}.ts`
|
|
26
|
-
| `entities[].fields[]` | the columns and row mapping above, the validation bounds in `src/api/endpoints.ts`, the form field and table cell in `src/client/*.tsrx`
|
|
27
|
-
| `fields[].unique: tenant` | a `(tenant_id, <field>)` unique index in the migration plus the stable conflict code in the service
|
|
28
|
-
| `entities[].states` | the status column, the transition guard in the service, the `Tag` tone in the view
|
|
29
|
-
| `screens[]` | `src/client/<Pascal>View.tsrx`, a `views` entry and a `navigation` entry in `src/client/contribution.tsrx`, `translations/*.json`
|
|
30
|
-
| `
|
|
31
|
-
| `screens[].
|
|
32
|
-
| `
|
|
33
|
-
| `
|
|
34
|
-
| `
|
|
35
|
-
| `
|
|
36
|
-
| `
|
|
37
|
-
| `
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
40
|
-
| `
|
|
41
|
-
| `
|
|
23
|
+
| Spec element | Files it produces |
|
|
24
|
+
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `entities[]` | `src/domain/types.ts`, `migrations/000N_<module>_<name>.{up,down}.sql`, `src/services/migration.ts`, `src/services/{repository,database-repository}.ts` |
|
|
26
|
+
| `entities[].fields[]` | the columns and row mapping above, the validation bounds in `src/api/endpoints.ts`, the form field and table cell in `src/client/*.tsrx` |
|
|
27
|
+
| `fields[].unique: tenant` | a `(tenant_id, <field>)` unique index in the migration plus the stable conflict code in the service |
|
|
28
|
+
| `entities[].states` | the status column, the transition guard in the service, the `Tag` tone in the view |
|
|
29
|
+
| `screens[]` | `src/client/<Pascal>View.tsrx`, a `views` entry and a `navigation` entry in `src/client/contribution.tsrx`, `translations/*.json` |
|
|
30
|
+
| `design/<screen>.html` | the approved look of that screen: mirror its structure and keep its copy. Render it with `pnpm ui:preview <path>`; absent, design it first with `ux-design` |
|
|
31
|
+
| `screens[].columns`/`filters` | the `TableColumn[]` outside the component, and the controls inside the `Filters` dropdown |
|
|
32
|
+
| `screens[].navigationGroup` | the navigation entry's `group` |
|
|
33
|
+
| `actions[]` | the service method with its error code, the `defineEndpoint` route, the fetch in `src/client/api.ts` |
|
|
34
|
+
| `widgets[]` | a widget component plus a `widgets` entry with its `slot` in `src/client/contribution.tsrx` |
|
|
35
|
+
| `settings[]` | `src/settings.ts` (`defineModuleSettings`) and `settings:` in `src/platform.ts`; a `kind: flag` entry keeps `kind: 'flag'` there, so owners switch it on the Flags tab of Administration, Modules |
|
|
36
|
+
| `agentTools[]` | `src/agent/tools.ts` and the `context.agentTools.register` call, as a separate `agent-tool-design` phase |
|
|
37
|
+
| `research` | `src/research.ts`, `research-fixtures.json`, `requires` of the capabilities, the evidence attach where the `evidenceOwner` record is written |
|
|
38
|
+
| `adapters[]` | `src/adapters/<name>.ts`, the port, the adapter registration, `adapters/<name>.recorded.json`, as a separate `integration-adapter` phase |
|
|
39
|
+
| `templates[]` | `templates/<name>.md`, `templates` in `package.json` `files`, `src/templates.ts` registered through `documents.templates.v1` in `src/platform.ts` |
|
|
40
|
+
| `permissions[]` | `src/acl/permissions.ts`, the endpoint `access.permission`, the client `scope` |
|
|
41
|
+
| `acceptanceScenarios[]` | `tests/module.test.ts` and its siblings, at least one case each |
|
|
42
|
+
| `outOfScope[]`, `decisions[]` | no code. Read them so you do not rebuild a decision or implement a deferred feature. |
|
|
42
43
|
|
|
43
44
|
A v1 spec stays valid and carries none of these arrays. Then the requirements are `invariants`, `permissions` and `acceptanceScenarios`, and everything the spec leaves open is still a question rather than a guess.
|
|
44
45
|
|
|
@@ -16,7 +16,7 @@ The spec-element to file mapping is the table in `module-new`; the change classe
|
|
|
16
16
|
|
|
17
17
|
## 1. Read first
|
|
18
18
|
|
|
19
|
-
Read the whole module before changing it: `spec/module.yaml`, `src/index.ts`, `src/acl/permissions.ts`, `src/api/endpoints.ts`, `src/services/*`, `src/client/*`, `tests/`. Keep every exported name in `src/index.ts`, `src/server/index.ts` and `src/client/index.ts` stable: other modules import them (`modules/users` uses `AuthRuntime` from `@flowdular/sdk/modules/auth/server`), and the generated composition imports `createServerComposition` and `createClientContribution`.
|
|
19
|
+
Read the whole module before changing it: `spec/module.yaml`, `design/*.html` when the change touches a screen, `src/index.ts`, `src/acl/permissions.ts`, `src/api/endpoints.ts`, `src/services/*`, `src/client/*`, `tests/`. Keep every exported name in `src/index.ts`, `src/server/index.ts` and `src/client/index.ts` stable: other modules import them (`modules/users` uses `AuthRuntime` from `@flowdular/sdk/modules/auth/server`), and the generated composition imports `createServerComposition` and `createClientContribution`.
|
|
20
20
|
|
|
21
21
|
Sandbox facts for an edit session (`packages/sandbox/src/server/sessions.ts`): the module is copied to `workspace/modules/<dir>` and a pristine copy to `base/modules/<dir>`; the diff shown to the operator and the eject plan compare the two. The workspace is a pnpm workspace of its own (declared dependencies install for real; a `package.json` change triggers a reinstall that counts as the `dependencies` gate). The business manager updates the spec before implementation. The operator approves the exact spec hash for every affected module; editing that spec, requesting changes, or adding another module reopens its approval gate. A sandbox specialist never writes `status: approved`; a host agent may invoke approval only after an explicit current user request through `spec-approval`. Delivery checks the recorded hash again.
|
|
22
22
|
|
|
@@ -55,9 +55,11 @@ New screen or widget:
|
|
|
55
55
|
3. `src/client/index.ts`: re-export the view.
|
|
56
56
|
4. Add user-facing copy to every declared `translations/*.json` bundle and resolve it with fully qualified `t()` keys. Navigation copy uses getters because contributions exist before bundles are registered.
|
|
57
57
|
|
|
58
|
+
New feature flag: a setting with `kind: 'flag'`, `type: 'boolean'`, `scope: 'tenant'` and a `defaultValue`. It reads like any other setting, `context.settings.get<boolean>(tenantId, '<module>.core', 'key')`, and appears on the Flags tab of Administration, Modules beside the module's other flags; a change is audited as `settings.flag.changed`. Declare it in the spec as `kind: flag` too, and state in an invariant what the module does while it is off. Before changing behaviour a workspace already relies on, removing a step, or adding an outward-facing or costly path, propose a flag and let the operator decide: ship the new path behind it, default off, and keep the old path working while it is off. A flag is on or off for one workspace; there is no percentage rollout and no targeting.
|
|
59
|
+
|
|
58
60
|
New setting:
|
|
59
61
|
|
|
60
|
-
1. `src/settings.ts`: `export const X_MODULE_SETTINGS = defineModuleSettings({ moduleId: '<module>.core', settings: { key: { type: 'string' | 'number' | 'boolean', defaultValue, visibility: 'private' | 'shared', client: boolean, scope: 'tenant' | 'platform', labelKey, label, descriptionKey, description, min?, max?, enum?, secret? } } })` from `@flowdular/sdk/kernel` (`packages/kernel/src/module-settings.ts`; setting keys match `^[a-z][a-zA-Z0-9]*$`). `labelKey` and `descriptionKey` are fully qualified module translation keys present in every locale. Keep the English literals as compatibility fallbacks; values, ids and secrets are never translated.
|
|
62
|
+
1. `src/settings.ts`: `export const X_MODULE_SETTINGS = defineModuleSettings({ moduleId: '<module>.core', settings: { key: { type: 'string' | 'number' | 'boolean', kind?: 'flag', defaultValue, visibility: 'private' | 'shared', client: boolean, scope: 'tenant' | 'platform', labelKey, label, descriptionKey, description, min?, max?, enum?, secret? } } })` from `@flowdular/sdk/kernel` (`packages/kernel/src/module-settings.ts`; setting keys match `^[a-z][a-zA-Z0-9]*$`). `labelKey` and `descriptionKey` are fully qualified module translation keys present in every locale. Keep the English literals as compatibility fallbacks; values, ids and secrets are never translated.
|
|
61
63
|
2. `src/platform.ts`: return `settings: X_MODULE_SETTINGS` next to `routes`; the platform declares it at boot and Administration, Modules renders it in the module's drawer (`modules/system/src/client/ModuleSettingsSection.tsrx`, behind `system.settings.read` and `system.settings.manage`; the API is `GET /api/settings` and `POST /api/settings/update` in `modules/system/src/server/endpoints.ts`).
|
|
62
64
|
3. Read it live where it is used: `context.settings.get<number>(tenantId, '<module>.core', 'key')` at request time, never cached at boot; pass `context.settings` into the runtime or service that needs it (`modules/agents/src/settings.ts`, `agentSettings`, shows the pattern with an environment fallback).
|
|
63
65
|
4. `spec/module.yaml`: an invariant or scenario naming the setting and its bounds; `specVersion` bump. Cross-module reads of a setting need `visibility: 'shared'` and a declared dependency.
|
|
@@ -23,25 +23,26 @@ For an edit, also read the module's current `spec/module.yaml` and write the sma
|
|
|
23
23
|
|
|
24
24
|
One pass, in this order. For each row, write the default from the card into the spec and record it as a `decisions[]` entry with `decidedBy: default`. Ask only where the answer is a business fact that no default can supply.
|
|
25
25
|
|
|
26
|
-
| Decision | Default to propose
|
|
27
|
-
| ---------------------- |
|
|
28
|
-
| Actors | Owner manages, member reads
|
|
29
|
-
| Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name
|
|
30
|
-
| Uniqueness | The human-facing code is `unique: tenant`; everything else `none`
|
|
31
|
-
| States and transitions | `active` and `archived`, every transition behind the manage permission
|
|
32
|
-
| Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope
|
|
33
|
-
| What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing
|
|
34
|
-
| Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400
|
|
35
|
-
| Cross-module reads | None. A read of another module goes through its public capability and a declared dependency
|
|
36
|
-
| Screens | One `list` screen with the entity's identifying columns
|
|
37
|
-
| Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it
|
|
38
|
-
| Settings | None. A number the business may change later is `scope: tenant` with a stated default
|
|
39
|
-
|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
43
|
-
|
|
|
44
|
-
|
|
|
26
|
+
| Decision | Default to propose | Lands in |
|
|
27
|
+
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- |
|
|
28
|
+
| Actors | Owner manages, member reads | `permissions`, `invariants` |
|
|
29
|
+
| Entities and fields | One primary entity; `name` required, `maxLength` 120; no field the request did not name | `entities[]` |
|
|
30
|
+
| Uniqueness | The human-facing code is `unique: tenant`; everything else `none` | `entities[].fields[].unique` |
|
|
31
|
+
| States and transitions | `active` and `archived`, every transition behind the manage permission | `entities[].states` |
|
|
32
|
+
| Who sees what | Both permissions in the same navigation entry; the manage action hidden without the scope | `permissions`, `screens[]`, `invariants` |
|
|
33
|
+
| What is denied | Unauthenticated 401, missing permission 403, cross-tenant read returns nothing | `acceptanceScenarios` |
|
|
34
|
+
| Failure behaviour | A duplicate returns a stable conflict and changes nothing; bounds return 400 | `invariants`, `acceptanceScenarios` |
|
|
35
|
+
| Cross-module reads | None. A read of another module goes through its public capability and a declared dependency | `dependencies`, `dataOwnership` |
|
|
36
|
+
| Screens | One `list` screen with the entity's identifying columns | `screens[]` |
|
|
37
|
+
| Widgets | None. A count belongs on `dashboard.metrics` only when the request asks for it | `widgets[]` |
|
|
38
|
+
| Settings | None. A number the business may change later is `scope: tenant` with a stated default | `settings[]` |
|
|
39
|
+
| Feature flags | Ask for one whenever a change alters behaviour a workspace already relies on, or is hard to undo: `kind: flag`, boolean, `scope: tenant`, a stated default, and the behaviour named in an invariant | `settings[]` |
|
|
40
|
+
| Agent tools | None. A tool is a later phase and `risk` may only be `read` or `workspace-write` | `agentTools[]` |
|
|
41
|
+
| Outside sources | None. A named public source is `research`, with the entity its findings attach to | `research` |
|
|
42
|
+
| Other systems | None. A named system is one `source` adapter per record kind, run on demand | `adapters[]` |
|
|
43
|
+
| Documents | None. A named document is one `templates[]` entry on the record it describes | `templates[]` |
|
|
44
|
+
| Reports | None. There is no export, no PDF and no search; a report is a screen or it is out of scope | `outOfScope[]` |
|
|
45
|
+
| Out of scope | Every item from the card's gap list the request touched, each with its business decision | `outOfScope[]`, `decisions[]` |
|
|
45
46
|
|
|
46
47
|
A default you propose is still a decision: it goes into `decisions[]` so the operator can see and overturn it, and so the next agent never re-derives it.
|
|
47
48
|
|
|
@@ -89,7 +90,7 @@ A number the case needs (a score, a premium, a price per square metre) is an `ac
|
|
|
89
90
|
- `screens[]`: `{ id, kind: list|record|form|dashboard, entity?, title?, columns?, filters?, navigationGroup? }`. `navigationGroup` is one of the six values on the card.
|
|
90
91
|
- `actions[]`: `{ id, entity?, permission, kind: create|update|delete|custom, risk, idempotent, description }`. `risk: external` is refused by the platform, so an action may not declare it.
|
|
91
92
|
- `widgets[]`: `{ id, slot, entity?, description }`; `slot` is one of the four workspace slots.
|
|
92
|
-
- `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, default?, values?, description }`.
|
|
93
|
+
- `settings[]`: `{ key, type: string|integer|boolean|enum, scope: tenant|platform, kind?: setting|flag, default?, values?, description }`. A `flag` is boolean, `scope: tenant` and carries a default; owners switch it per workspace on the Flags tab of Administration, Modules, and every change is audited. Ask whether the new behaviour ships behind one: a change to something a workspace already does, an outward-facing action, a costly or slow path, or anything a business would want to turn off without a deployment. Say what the module does with the flag off, as an invariant.
|
|
93
94
|
- `agentTools[]`: `{ id, permission, description, risk: read|workspace-write }`.
|
|
94
95
|
- `outOfScope[]`: plain sentences, each naming the gap and the decision taken instead.
|
|
95
96
|
- `decisions[]`: `{ id, question, answer, decidedBy: user|default }`; ids match `^[A-Z][A-Z0-9-]+$`, for example `D-UNIQUE-SKU`.
|
|
@@ -93,7 +93,28 @@ Layout `ui-view`, `ui-two-col` (+`--wide-aside`), `ui-grid-2`, `ui-kpi-grid`, `u
|
|
|
93
93
|
|
|
94
94
|
User-facing copy lives in every declared `translations/*.json` bundle and is read with fully qualified `t()` keys. Eyebrow names the domain, title names the records, and description is one sentence. Table headers say what the value is. Buttons start with a verb. Loading text ends with `…`. Drawer footer states the constraint the user cannot see. Write natural copy in each locale, with no exclamation marks or database jargon.
|
|
95
95
|
|
|
96
|
-
## 7.
|
|
96
|
+
## 7. Design the screen as a preview first
|
|
97
|
+
|
|
98
|
+
A design is a file, not a description: `modules/<dir>/design/<screen>.html`, beside the spec. It is markup only, dressed by the platform's own stylesheets, so it shows what the screen will look like before a component exists and long before the application boots. An implementation phase reads it the way it reads the spec.
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --scaffold # the record recipe in all five states
|
|
102
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html # renders it, prints a file:// address
|
|
103
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --shot .flowdular/ui-preview/<screen>.png
|
|
104
|
+
pnpm ui:preview modules/<dir>/design/<screen>.html --open # shows it to the person asking
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Rules that keep a preview honest:
|
|
108
|
+
|
|
109
|
+
- Markup only. No `<style>`, no CSS rule, no `<html>` or `<body>`; the command refuses a fragment that carries one. Every visual decision comes from `packages/ui`, which is what stops a preview from becoming a second source of truth.
|
|
110
|
+
- One `<section class="ui-view" data-state="...">` per state: `populated`, `loading`, `empty`, `error`, `denied`. The command labels each one, so a single page answers for all five.
|
|
111
|
+
- Real content. The longest realistic name, a real identifier, the copy the screen will actually carry. A preview of `Lorem ipsum` proves nothing about overflow or alignment.
|
|
112
|
+
- Only classes a stylesheet declares. `pnpm ui-classes:check` fails on a class nothing defines, in a preview and in a `.tsrx` alike, which is the one mistake that compiles, passes its tests and renders unstyled. A class a module needs and the design system lacks is declared in the module's own CSS, as rule 4 says.
|
|
113
|
+
- `--shot` writes a PNG, which is how an agent with no browser looks at its own work. `--open` opens the preview in the person's browser, which is how a design is shown for approval; a correction loop renders without it rather than throwing a window at whoever is at the keyboard.
|
|
114
|
+
|
|
115
|
+
Hand off the path, not a description. An implementation phase opens the preview, mirrors its structure with the components in section 4, and keeps the copy.
|
|
116
|
+
|
|
117
|
+
## 8. Inspect the rendered screen
|
|
97
118
|
|
|
98
119
|
A screen is not finished until it has been looked at. Typecheck and tests say nothing about overflow, alignment, a duplicate label or a column that collapses.
|
|
99
120
|
|
|
@@ -117,6 +138,7 @@ Check the drawer form in the same pass: one label per field, fields top-aligned,
|
|
|
117
138
|
|
|
118
139
|
## Pitfalls
|
|
119
140
|
|
|
141
|
+
- A preview that renders unstyled is a class nothing declares, not a broken harness; run `pnpm ui-classes:check`.
|
|
120
142
|
- `Kpi value={items.length}` does not typecheck; use `String(items.length)`.
|
|
121
143
|
- A `Tag` for a lifecycle state uses `success` for active and `neutral` for archived, with `dot`.
|
|
122
144
|
- An `Icon` inside `Button size="sm"` is 14, not 18.
|
|
@@ -33,6 +33,9 @@ flowdular module version <id> # version, platformApi range,
|
|
|
33
33
|
flowdular module version bump <id> <level> [--apply] # patch|minor|major across module.json, package.json, specVersion and dependent ranges
|
|
34
34
|
flowdular module new <id> --spec <path> [--apply] # scaffold from an approved spec
|
|
35
35
|
flowdular module enable|disable <id> [--apply] # composition and scope grants
|
|
36
|
+
flowdular web list # the addresses this workspace serves module pages at
|
|
37
|
+
flowdular web mount <module id> <surface id> --path <path> --tenant <id> [--id <mount id>] [--apply]
|
|
38
|
+
flowdular web unmount <mount id> [--apply] # stop serving that site
|
|
36
39
|
flowdular migration status [--module <id>] # migration ledger
|
|
37
40
|
flowdular migration apply --module <id> [--apply]
|
|
38
41
|
flowdular migration verify # checksum drift, row security, file and constant parity
|
|
@@ -583,6 +583,23 @@ deployment on the old names keeps working and the server logs one
|
|
|
583
583
|
replacement. The platform name wins when both are set, and every refusal names
|
|
584
584
|
the variable the deployment actually set.
|
|
585
585
|
|
|
586
|
+
The relay is also five platform-scoped `auth.core` settings, edited under
|
|
587
|
+
Administration, Modules: `mailTransport` (`environment`, `none` or `smtp`),
|
|
588
|
+
`mailSmtpUrl` (secret, write only), `mailFrom`, `mailRequireTls` and
|
|
589
|
+
`mailRejectUnauthorized`. `mailTransport` decides which source wins. It is
|
|
590
|
+
`environment` by default, and while it stays there the `FD_MAIL_*`
|
|
591
|
+
configuration above is in effect exactly as described, deprecation warnings
|
|
592
|
+
included; storing `none` or `smtp` overrides the environment for every sender of
|
|
593
|
+
the installation. `development` is reachable only through the environment and is
|
|
594
|
+
never settable from the UI. A stored relay is resolved when a message is sent,
|
|
595
|
+
so a change carries the next message without a restart, and the built transport
|
|
596
|
+
is cached by a digest of the effective configuration. Storing `smtp` without a
|
|
597
|
+
relay URL or without a sender is refused, as is a URL that is not `smtp://` or
|
|
598
|
+
`smtps://` and a sender the sender rule rejects; the refusal names the setting
|
|
599
|
+
and never carries its value. Administration, Settings states which source and
|
|
600
|
+
which transport are in effect and sends a test message to the signed-in
|
|
601
|
+
address.
|
|
602
|
+
|
|
586
603
|
`none` refuses every message with `MAIL_NOT_CONFIGURED` and sends nothing.
|
|
587
604
|
`development` keeps the last 100 messages in memory for a local run and a test
|
|
588
605
|
and is refused at boot in production, where it would be silent data loss.
|
|
@@ -17,6 +17,12 @@ shared primitives, tokens, and the rules for using them.
|
|
|
17
17
|
- Modules: compose screens from `@flowdular/sdk/ui`. Module CSS may only add
|
|
18
18
|
module-specific composites built on the tokens (example:
|
|
19
19
|
`modules/agents/src/client/agents.css`).
|
|
20
|
+
- `modules/<dir>/design/*.html`: a screen as markup, rendered with the
|
|
21
|
+
stylesheets above by `pnpm ui:preview <path>` (`--scaffold` writes the record
|
|
22
|
+
recipe in all five states, `--shot <file.png>` captures it). It is where a
|
|
23
|
+
screen is designed and agreed before it is a component, and it carries no CSS
|
|
24
|
+
of its own, so it cannot drift from the platform. `pnpm ui-classes:check`
|
|
25
|
+
refuses a class no stylesheet declares, there and in every `.tsrx`.
|
|
20
26
|
- `platform/public`: `favicon.svg`, `og.png` (1200x630 Open Graph image).
|
|
21
27
|
- Brand mark geometry is generated: `node packages/ui/scripts/gen-mark.mjs`
|
|
22
28
|
rewrites `packages/ui/src/brand/mark.ts` from the weave parameters.
|
|
@@ -407,7 +413,8 @@ Icon names (`packages/ui/src/icons/Icon.tsrx`): `dashboard`, `parties`, `catalog
|
|
|
407
413
|
- Drawer: `ui-drawer__form` (scrolling body plus pinned footer),
|
|
408
414
|
`ui-drawer__body`, `ui-drawer__foot`
|
|
409
415
|
- Settings rows inside a `ui-card` or a drawer `ui-form__section`, rendered
|
|
410
|
-
by `SettingRow`: `ui-setting`
|
|
416
|
+
by `SettingRow`: `ui-setting`, grouped in a card by `ui-setting-group` with
|
|
417
|
+
`ui-setting-group__head` (the group's name on the rows' own inset)
|
|
411
418
|
(+`__text` for title, scope tag, and help, `__control` for the one-line
|
|
412
419
|
control cluster, `__status` (+`--error`) for the inline result)
|
|
413
420
|
- Buttons: `ui-btn` with `--primary`, `--secondary`, `--ghost`, `--danger`,
|
|
@@ -63,7 +63,27 @@ runtime and the module's existing bundles when localization is needed.
|
|
|
63
63
|
|
|
64
64
|
## Operator configuration
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
One command writes the mount and regenerates the composition:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pnpm flowdular web mount example.core public --path /blog --tenant <tenant id> # preview
|
|
70
|
+
pnpm flowdular web mount example.core public --path /blog --tenant <tenant id> --apply
|
|
71
|
+
pnpm flowdular web list
|
|
72
|
+
pnpm flowdular web unmount acme-public --apply
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
It refuses before it writes what the platform would refuse at boot: a module
|
|
76
|
+
this workspace has not enabled, a reserved address, the configured backoffice
|
|
77
|
+
path, an address overlapping a site already mounted, and a mount naming no
|
|
78
|
+
workspace. The mount id defaults to the module's first segment; `--id` names it
|
|
79
|
+
when one module serves several addresses, and mounting the same id again moves
|
|
80
|
+
that site rather than adding a second one.
|
|
81
|
+
|
|
82
|
+
The tenant is the workspace whose pages are served, so it exists before the
|
|
83
|
+
mount does: run `flowdular setup` first and use the id it reports.
|
|
84
|
+
|
|
85
|
+
The same section can be written by hand in the installation's
|
|
86
|
+
`flowdular.json`:
|
|
67
87
|
|
|
68
88
|
```json
|
|
69
89
|
{
|
|
@@ -82,7 +102,8 @@ Add the optional `web` section in the installation's `flowdular.json`:
|
|
|
82
102
|
}
|
|
83
103
|
```
|
|
84
104
|
|
|
85
|
-
|
|
105
|
+
A hand-written section needs `pnpm flowdular module sync --apply` afterwards;
|
|
106
|
+
the `web` commands run it themselves. Then rebuild or redeploy production, or
|
|
86
107
|
restart development. Configuration is generated into the server composition;
|
|
87
108
|
changing it requires regeneration. It is not read from a process-local settings
|
|
88
109
|
cache or an anonymous query parameter. Verify the tenant ID before publishing.
|
|
@@ -91,7 +112,14 @@ paths. `/records/:slug` above becomes `/blog/records/:slug`.
|
|
|
91
112
|
|
|
92
113
|
The root `/` can host a public storefront, alongside more specific mounts such as
|
|
93
114
|
`/blog`. Reserved platform prefixes such as `/app`, `/auth`, `/api`, `/setup` and
|
|
94
|
-
the configured backoffice path cannot be claimed by modules
|
|
115
|
+
the configured backoffice path cannot be claimed by modules; the list is
|
|
116
|
+
`RESERVED_WEB_SEGMENTS` in `@flowdular/sdk/contracts`, which the composition and the
|
|
117
|
+
CLI both read. A segment may carry dots inside it, so a page answers at
|
|
118
|
+
`/rss.xml`, `/sitemap.xml` or `/robots.txt` as a reader or a crawler expects;
|
|
119
|
+
each dot separates two non-empty groups, which keeps `..`, a leading dot and a
|
|
120
|
+
trailing dot out of every address. A mount naming a surface its module does not declare stops the
|
|
121
|
+
composition with that sentence, rather than answering 404 at the address for the
|
|
122
|
+
life of the deployment. Other overlapping
|
|
95
123
|
mounts and equivalent route patterns are rejected. Custom paths such as `/blog`, `/portal` and `/forms/contact` work without a
|
|
96
124
|
tenant ID in the URL. `/sites/` is an optional convention for multiple sites;
|
|
97
125
|
unknown sites under that prefix return 404. A disabled binding
|
package/package.json
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertRouteConflicts,
|
|
3
|
+
createCorsMiddleware,
|
|
3
4
|
createModuleMetrics,
|
|
4
5
|
createModuleWebRoutes,
|
|
5
6
|
createApplicationRoutes,
|
|
7
|
+
createOpenApiRoutes,
|
|
6
8
|
serverTracer,
|
|
7
9
|
validateApplicationPath,
|
|
8
10
|
createMailPort,
|
|
@@ -17,6 +19,7 @@ import { defineConfig, RenderRoute } from '@octanejs/vite-plugin';
|
|
|
17
19
|
import {
|
|
18
20
|
authRuntimeOptionsFromEnvironment,
|
|
19
21
|
createAuthRoutes,
|
|
22
|
+
endpointIdentityFromContext,
|
|
20
23
|
isTokenPrincipal,
|
|
21
24
|
mfaEnrolmentSatisfied,
|
|
22
25
|
principalFromContext,
|
|
@@ -123,7 +126,10 @@ const moduleCompositions = composeModuleServer({
|
|
|
123
126
|
dataClasses,
|
|
124
127
|
databases,
|
|
125
128
|
storage,
|
|
126
|
-
|
|
129
|
+
/* The relay auth.core resolves per message from its stored settings, falling
|
|
130
|
+
back to the environment port above, so every module of the installation
|
|
131
|
+
sends through the same one. */
|
|
132
|
+
mail: authRuntime.mail,
|
|
127
133
|
/* Rebound to the composing module by the generated composition; this binding
|
|
128
134
|
is what a series the platform itself records would carry. */
|
|
129
135
|
metrics: createModuleMetrics('platform'),
|
|
@@ -172,7 +178,15 @@ if (building) {
|
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
export default defineConfig({
|
|
175
|
-
middlewares: [
|
|
181
|
+
middlewares: [
|
|
182
|
+
/* Ahead of the authentication: a cross-origin preflight carries no
|
|
183
|
+
credential and has to be answered before anything asks for one. The
|
|
184
|
+
origins come from the API tokens this workspace issued. */
|
|
185
|
+
createCorsMiddleware({
|
|
186
|
+
allowOrigin: (origin) => authRuntime.apiOriginAllowed(origin),
|
|
187
|
+
}),
|
|
188
|
+
authRuntime.middleware,
|
|
189
|
+
],
|
|
176
190
|
router: {
|
|
177
191
|
routes: checkedRoutes([
|
|
178
192
|
...createApplicationRoutes({
|
|
@@ -183,6 +197,12 @@ export default defineConfig({
|
|
|
183
197
|
healthEndpoint.serverRoute,
|
|
184
198
|
createReadinessEndpoint(databases).serverRoute,
|
|
185
199
|
...createMetricsRoutes({ environment: process.env }),
|
|
200
|
+
/* Describes every operation the presented credential may call, built
|
|
201
|
+
from the endpoints this application composed. */
|
|
202
|
+
...createOpenApiRoutes({
|
|
203
|
+
resolveIdentity: endpointIdentityFromContext,
|
|
204
|
+
publicBaseUrl: authRuntime.publicBaseUrl,
|
|
205
|
+
}),
|
|
186
206
|
...createStorageRoutes({
|
|
187
207
|
storage,
|
|
188
208
|
keyring: storageKeyring,
|
|
@@ -194,6 +214,10 @@ export default defineConfig({
|
|
|
194
214
|
modules: moduleCompositions,
|
|
195
215
|
mounts: moduleWebMounts,
|
|
196
216
|
applicationPath: configuredApplicationPath,
|
|
217
|
+
/* A build composes with NODE_ENV forced to development, so the flag
|
|
218
|
+
asks for both: only a development server serves a page whose
|
|
219
|
+
stylesheets arrive after its markup. */
|
|
220
|
+
development: !building && process.env.NODE_ENV !== 'production',
|
|
197
221
|
resolveIdentity: async (context) => {
|
|
198
222
|
const principal = principalFromContext(context);
|
|
199
223
|
if (!principal) return null;
|