create-flowdular 0.2.6 → 0.3.0
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/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.agents/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.agents/skills/deploy-operate/SKILL.md +109 -0
- package/agent-template/.agents/skills/module-new/SKILL.md +29 -0
- package/agent-template/.agents/skills/module-update/SKILL.md +9 -1
- package/agent-template/.agents/skills/spec-interview/SKILL.md +114 -0
- package/agent-template/.agents/skills/ux-design/SKILL.md +34 -3
- package/agent-template/.ai/README.md +2 -1
- package/agent-template/.ai/agents/sandbox/business-manager.md +5 -1
- package/agent-template/.ai/blueprints/author-spec/README.md +1 -1
- package/agent-template/.ai/blueprints/author-spec/spec-requirements.yaml +44 -0
- package/agent-template/.ai/blueprints/author-spec/steps.yaml +5 -5
- package/agent-template/.ai/blueprints/author-spec/templates/module.yaml +99 -3
- package/agent-template/.ai/blueprints/edit-module/gates.yaml +4 -0
- package/agent-template/.ai/blueprints/edit-module/required-files.yaml +9 -0
- package/agent-template/.ai/blueprints/new-module/gates.yaml +4 -0
- package/agent-template/.ai/blueprints/new-module/spec-requirements.yaml +2 -2
- package/agent-template/.ai/blueprints/release/gates.yaml +4 -0
- package/agent-template/.ai/platform-capabilities.md +128 -0
- package/agent-template/.ai/policies/capabilities.yaml +100 -0
- package/agent-template/.ai/policies/path-ownership.yaml +5 -2
- package/agent-template/.ai/policies/task-budgets.yaml +5 -3
- package/agent-template/.ai/references/catalog/module.json +4 -4
- package/agent-template/.ai/references/catalog/package.json +2 -2
- package/agent-template/.ai/references/catalog/spec/module.yaml +5 -3
- package/agent-template/.ai/references/catalog/src/platform.ts +2 -0
- package/agent-template/.ai/references/catalog/src/services/catalog-service.ts +89 -1
- package/agent-template/.ai/references/catalog/src/services/data-classes.ts +47 -0
- package/agent-template/.ai/references/catalog/src/services/database-repository.ts +98 -1
- package/agent-template/.ai/references/catalog/src/services/repository.ts +22 -1
- package/agent-template/.ai/references/catalog/tests/data-classes.test.ts +157 -0
- package/agent-template/.ai/references/catalog.provenance.json +12 -10
- package/agent-template/.ai/rules/flowdular.md +4 -0
- package/agent-template/.ai/skills/README.md +10 -0
- package/agent-template/.ai/skills/agent-tool-design/SKILL.md +1 -2
- package/agent-template/.ai/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.ai/skills/business-agent-design/SKILL.md +0 -1
- package/agent-template/.ai/skills/deploy-operate/SKILL.md +114 -0
- package/agent-template/.ai/skills/module-new/SKILL.md +29 -3
- package/agent-template/.ai/skills/module-update/SKILL.md +9 -3
- package/agent-template/.ai/skills/perf-audit/SKILL.md +0 -1
- package/agent-template/.ai/skills/release-eject-pr/SKILL.md +0 -1
- package/agent-template/.ai/skills/spec-interview/SKILL.md +120 -0
- package/agent-template/.ai/skills/test-hardening/SKILL.md +1 -0
- package/agent-template/.ai/skills/ux-design/SKILL.md +34 -3
- package/agent-template/.ai/skills/variables/SKILL.md +0 -2
- package/agent-template/.ai/skills/workflow-development/SKILL.md +0 -1
- package/agent-template/.claude/skills/agent-tool-design/SKILL.md +1 -1
- package/agent-template/.claude/skills/auth-security-review/SKILL.md +1 -1
- package/agent-template/.claude/skills/deploy-operate/SKILL.md +109 -0
- package/agent-template/.claude/skills/module-new/SKILL.md +29 -0
- package/agent-template/.claude/skills/module-update/SKILL.md +9 -1
- package/agent-template/.claude/skills/spec-interview/SKILL.md +114 -0
- package/agent-template/.claude/skills/ux-design/SKILL.md +34 -3
- package/agent-template/AGENTS.md +4 -0
- package/agent-template/CLAUDE.md +4 -0
- package/agent-template/docs/adr/0003-module-settings.md +1 -1
- package/agent-template/docs/adr/0006-agentic-workflows.md +24 -21
- package/agent-template/docs/agent-contract.md +2 -2
- package/agent-template/docs/cli-extensions.md +82 -0
- package/agent-template/docs/cli.md +190 -0
- package/agent-template/docs/configuration.md +593 -36
- package/agent-template/docs/design-system.md +185 -31
- package/agent-template/docs/getting-started.md +118 -0
- package/agent-template/docs/module-distribution.md +96 -0
- package/agent-template/docs/module-web-surfaces.md +221 -0
- package/agent-template/docs/modules.md +216 -0
- package/agent-template/docs/operations.md +464 -0
- package/agent-template/docs/sandbox.md +212 -0
- package/agent-template/platform/scripts/build.mjs +10 -0
- package/dist/bin.js +28 -0
- package/package.json +1 -1
- package/template/default/.dockerignore +14 -0
- package/template/default/.env.example +94 -0
- package/template/default/README.md +37 -1
- package/template/default/flowdular.json +15 -4
- package/template/default/infra/README.md +116 -0
- package/template/default/infra/docker/Dockerfile +37 -0
- package/template/default/infra/docker/compose.yaml +158 -0
- package/template/default/infra/docker/postgres/10-roles.sh +31 -0
- package/template/default/infra/docker/postgres/tls-init.sh +28 -0
- package/template/default/infra/kubernetes/database-secret.example.yaml +15 -0
- package/template/default/infra/kubernetes/deployment.yaml +211 -0
- package/template/default/infra/kubernetes/kustomization.yaml +9 -0
- package/template/default/infra/kubernetes/secrets.example.yaml +52 -0
- package/template/default/infra/kubernetes/service.yaml +13 -0
- package/template/default/modules/example/module.json +2 -1
- package/template/default/modules/example/package.json +1 -1
- package/template/default/modules/example/spec/module.yaml +1 -1
- package/template/default/modules/example/src/services/database-repository.ts +2 -12
- package/template/default/package.json +3 -2
- package/template/default/platform/octane.config.ts +99 -9
- package/template/default/platform/package.json +1 -1
- package/template/default/platform/src/generated/modules.client.ts +26 -2
- package/template/default/platform/src/generated/modules.server.ts +241 -10
- package/template/default/platform/src/server/health.ts +47 -0
- package/template/default/platform/src/server/metrics.ts +100 -0
- package/template/default/platform/src/server/storage.ts +172 -0
- package/template/default/platform/src/server/tracing.ts +85 -0
- package/template/default/specs/application.yaml +15 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Module-owned web surfaces
|
|
2
|
+
|
|
3
|
+
A module can provide workspace administration, HTTP APIs and independently laid
|
|
4
|
+
out web pages in the same installation. Web pages are optional server composition
|
|
5
|
+
contributions. They do not render `ApplicationShell` or `AuthenticationCore`.
|
|
6
|
+
Access and presentation are independent: a standalone portal may still require
|
|
7
|
+
authentication or a permission.
|
|
8
|
+
|
|
9
|
+
## Module declaration
|
|
10
|
+
|
|
11
|
+
Use the normal approved module specification and `platform.server: true` entry.
|
|
12
|
+
Declare the public interface and its visibility rules in that specification.
|
|
13
|
+
No new manifest capability or separate business module is required.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { defineWebSurface } from '@flowdular/sdk/server';
|
|
17
|
+
import type { ModuleServerComposition } from '@flowdular/sdk/server';
|
|
18
|
+
|
|
19
|
+
export function createServerComposition(): ModuleServerComposition {
|
|
20
|
+
return {
|
|
21
|
+
routes: [], // Existing protected administration APIs can remain here.
|
|
22
|
+
web: [
|
|
23
|
+
defineWebSurface({
|
|
24
|
+
id: 'public',
|
|
25
|
+
pages: [
|
|
26
|
+
{
|
|
27
|
+
id: 'record',
|
|
28
|
+
path: '/records/:slug',
|
|
29
|
+
entry: ['RecordPage', '@example/module/web'],
|
|
30
|
+
layout: '@example/module/web-layout',
|
|
31
|
+
access: { kind: 'public' },
|
|
32
|
+
async load({ site, params, signal }) {
|
|
33
|
+
// Call your module's service with site.tenantId.
|
|
34
|
+
// Read inside a tenant-scoped, read-only transaction.
|
|
35
|
+
// Return 404 if the record is missing or is not public.
|
|
36
|
+
return { title: 'Public record', slug: params.slug ?? '' };
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
}),
|
|
41
|
+
],
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Add browser-safe package exports for the page and optional layout:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"exports": {
|
|
51
|
+
"./platform": "./src/platform.ts",
|
|
52
|
+
"./web": "./src/web/RecordPage.tsrx",
|
|
53
|
+
"./web-layout": "./src/web/Layout.tsrx"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Use package subpaths, not workspace absolute paths. These entries go through
|
|
59
|
+
Octane's normal development and production SSR/hydration compilation. Keep
|
|
60
|
+
database, credentials and server imports out of both page and layout. Declare
|
|
61
|
+
all imported dependencies in the package manifest. Reuse the shared translation
|
|
62
|
+
runtime and the module's existing bundles when localization is needed.
|
|
63
|
+
|
|
64
|
+
## Operator configuration
|
|
65
|
+
|
|
66
|
+
Add the optional `web` section in the installation's `flowdular.json`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"web": {
|
|
71
|
+
"mounts": [
|
|
72
|
+
{
|
|
73
|
+
"id": "acme-public",
|
|
74
|
+
"moduleId": "example.core",
|
|
75
|
+
"surfaceId": "public",
|
|
76
|
+
"path": "/blog",
|
|
77
|
+
"tenantId": "the-existing-acme-tenant-id",
|
|
78
|
+
"enabled": true
|
|
79
|
+
}
|
|
80
|
+
]
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Run `pnpm flowdular module sync --apply`, then rebuild/redeploy production or
|
|
86
|
+
restart development. Configuration is generated into the server composition;
|
|
87
|
+
changing it requires regeneration. It is not read from a process-local settings
|
|
88
|
+
cache or an anonymous query parameter. Verify the tenant ID before publishing.
|
|
89
|
+
The same module surface can be mounted for multiple tenants under different
|
|
90
|
+
paths. `/records/:slug` above becomes `/blog/records/:slug`.
|
|
91
|
+
|
|
92
|
+
The root `/` can host a public storefront, alongside more specific mounts such as
|
|
93
|
+
`/blog`. Reserved platform prefixes such as `/app`, `/auth`, `/api`, `/setup` and
|
|
94
|
+
the configured backoffice path cannot be claimed by modules. Other overlapping
|
|
95
|
+
mounts and equivalent route patterns are rejected. Custom paths such as `/blog`, `/portal` and `/forms/contact` work without a
|
|
96
|
+
tenant ID in the URL. `/sites/` is an optional convention for multiple sites;
|
|
97
|
+
unknown sites under that prefix return 404. A disabled binding
|
|
98
|
+
or an uninstalled/disabled module leaves its configured address returning 404.
|
|
99
|
+
Keep that disabled binding when retiring an address so it cannot fall through
|
|
100
|
+
to legacy workspace routing. Remove the binding only when releasing the address.
|
|
101
|
+
|
|
102
|
+
A mount owns the addresses its pages declare. A mount under a custom path also
|
|
103
|
+
answers 404 below its own base, so do not put a `platform/public` file there.
|
|
104
|
+
A root mount claims nothing beyond its pages: the host serves built assets,
|
|
105
|
+
`platform/public` files and, in development, Vite's module and transform
|
|
106
|
+
requests from addresses the router leaves unmatched, and answers 404 for the
|
|
107
|
+
rest. An unknown public URL under a root mount therefore still returns 404, and
|
|
108
|
+
a root mount never shadows `/assets/...`, `/favicon.svg` or `/@vite/client`.
|
|
109
|
+
A deployment that wraps the exported handler itself must serve `dist/client`
|
|
110
|
+
in front of it, as the bundled Node server does.
|
|
111
|
+
|
|
112
|
+
### Backoffice address
|
|
113
|
+
|
|
114
|
+
The first-run setup includes **Backoffice address**, defaulting to `/app` (or the
|
|
115
|
+
installation's configured default). Choose `/backoffice` to leave `/` available
|
|
116
|
+
for a storefront. Setup validates the address, includes it in the review, and
|
|
117
|
+
writes `FD_APPLICATION_PATH=/backoffice` alongside the database settings. Restart
|
|
118
|
+
after setup; no client rebuild is needed for this environment setting. On a
|
|
119
|
+
read-only deployment setup provides the environment block to paste into the
|
|
120
|
+
hosting service. If `FD_APPLICATION_PATH` already exists in the environment, it
|
|
121
|
+
is authoritative; setup cannot silently replace it.
|
|
122
|
+
|
|
123
|
+
For configuration managed in source control, add:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"application": { "path": "/backoffice" },
|
|
128
|
+
"web": {
|
|
129
|
+
"mounts": [
|
|
130
|
+
{
|
|
131
|
+
"id": "store",
|
|
132
|
+
"moduleId": "example.core",
|
|
133
|
+
"surfaceId": "public",
|
|
134
|
+
"path": "/",
|
|
135
|
+
"tenantId": "the-existing-tenant-id"
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Run module sync and rebuild after changing `flowdular.json`. At startup,
|
|
143
|
+
`FD_APPLICATION_PATH` overrides `application.path`; `/app` is the fallback.
|
|
144
|
+
The value is installation-wide, one lowercase path segment of at most 64
|
|
145
|
+
characters. Navigation, authentication redirects and dashboard deep links use
|
|
146
|
+
it. When changed from `/app`, old `/app/...` bookmarks redirect permanently to
|
|
147
|
+
the configured address, preserving the suffix and query. Authentication pages
|
|
148
|
+
remain under `/auth/...`. A root mount removes the legacy slug-first dashboard
|
|
149
|
+
aliases; unknown public URLs return 404 instead of displaying the dashboard.
|
|
150
|
+
|
|
151
|
+
This release supports path mounts. Custom hostname binding, DNS ownership and
|
|
152
|
+
TLS provisioning are a separate deployment feature. It does not download or
|
|
153
|
+
activate executable modules at request time.
|
|
154
|
+
|
|
155
|
+
## Page and hydration
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
import { Head, Seo } from '@octanejs/seo';
|
|
159
|
+
import { webPageData } from '@flowdular/sdk/client/web';
|
|
160
|
+
import type { RenderRouteProps } from '@octanejs/vite-plugin';
|
|
161
|
+
|
|
162
|
+
export function RecordPage(props: RenderRouteProps) @{
|
|
163
|
+
const record = webPageData<{ title: string; slug: string }>(props);
|
|
164
|
+
<Head>
|
|
165
|
+
<Seo title={record.title} description="A public record" />
|
|
166
|
+
<main><h1>{record.title}</h1><p>{record.slug}</p></main>
|
|
167
|
+
</Head>
|
|
168
|
+
}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`load` runs before rendering, so it can return an actual 404 or redirect before
|
|
172
|
+
streaming starts. Its JSON result is the only data serialized into the page;
|
|
173
|
+
the authentication state, database handles and the rest of `Context.state` are
|
|
174
|
+
never serialized. `webPageData` reads that exact DTO on the server and during
|
|
175
|
+
hydration, with no second fetch. A layout receives the usual Octane page props
|
|
176
|
+
and `children`. Pages own their metadata, styling and UI states.
|
|
177
|
+
|
|
178
|
+
For an explicit refresh or client navigation, use
|
|
179
|
+
`loadWebPage<T>(url, abortSignal)` from the same client subpath. It requests the
|
|
180
|
+
same URL with `Accept: application/vnd.flowdular.page+json`, which runs the same
|
|
181
|
+
tenant, access and loader checks. Native links and deep-link refresh also work.
|
|
182
|
+
The page loader and data representation accept only GET/HEAD, never mutations.
|
|
183
|
+
Existing module endpoints remain the mechanism for writes; they must retain
|
|
184
|
+
their permission, CSRF and input-validation rules.
|
|
185
|
+
|
|
186
|
+
## Access and safety
|
|
187
|
+
|
|
188
|
+
- `public`: the loader's identity is always null, even if the visitor has a
|
|
189
|
+
dashboard session for another tenant. Data ownership comes from `site.tenantId`.
|
|
190
|
+
- `authenticated`: requires an authenticated principal whose active tenant
|
|
191
|
+
matches the configured site tenant. Otherwise returns 401 or 403.
|
|
192
|
+
- `permission`: additionally requires the declared permission, for example
|
|
193
|
+
`{ kind: 'permission', permission: 'example.records.read' }`.
|
|
194
|
+
|
|
195
|
+
The module must enforce record visibility inside its own service. Tenant RLS
|
|
196
|
+
alone does not hide private records from a public visitor to that same tenant.
|
|
197
|
+
Never reuse an unrestricted administration query as a public loader. Return
|
|
198
|
+
`new Response('Not found', { status: 404 })` for inaccessible content. Use the
|
|
199
|
+
request signal to cancel database or external work and bound query inputs.
|
|
200
|
+
|
|
201
|
+
All responses use `Cache-Control: no-store`; the HTML and JSON representations
|
|
202
|
+
vary on `Accept`. Shared/CDN caching is deliberately disabled until an explicit
|
|
203
|
+
tenant-aware invalidation policy is configured in a future extension. Page JSON
|
|
204
|
+
is bounded to 1 MiB. Unexpected failures return safe errors without raw secrets.
|
|
205
|
+
The host lifecycle drains response streams on completion, cancellation, errors
|
|
206
|
+
and request abort before releasing module resources.
|
|
207
|
+
|
|
208
|
+
Installed modules remain trusted code in the host process. Module composition
|
|
209
|
+
types now live in the shared server API; auth exports compatibility aliases for
|
|
210
|
+
existing modules. This extension is not a runtime security sandbox.
|
|
211
|
+
|
|
212
|
+
## Validation
|
|
213
|
+
|
|
214
|
+
Run module typechecks and tests, `pnpm verify`, and `pnpm build`. Cover public
|
|
215
|
+
and denied access, two tenants using the same slug, draft/private records,
|
|
216
|
+
redirects, 404s, deep links, cancellation and DTO contents. The shared production
|
|
217
|
+
integration test is `node scripts/smoke-web.mjs`; it creates an isolated fixture,
|
|
218
|
+
runs the real CLI composition generator, compiles the page and layout, and
|
|
219
|
+
checks production HTTP behavior. `FD_WEB_SMOKE_KEEP=true` retains its temporary
|
|
220
|
+
server for manual hydration and keyboard checks. No operator module is enabled
|
|
221
|
+
and no real tenant data is used by that test.
|
|
@@ -22,6 +22,37 @@ spec is the contract: `permissions[].id` equal the constants in
|
|
|
22
22
|
pnpm flowdular spec validate --all
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
`schemaVersion: 2` adds the domain model, so an implementing agent reads the
|
|
26
|
+
spec instead of scanning the repository. Every section is optional and a
|
|
27
|
+
`schemaVersion: 1` spec stays valid unchanged; version 1 rejects these keys.
|
|
28
|
+
|
|
29
|
+
- `entities[]`: `id`, `name`, `fields[]` (`id`, `type` of `string`, `text`,
|
|
30
|
+
`integer`, `decimal`, `boolean`, `date`, `datetime`, `enum`, `reference` or
|
|
31
|
+
`json`, plus `required`, `unique: tenant|none`, `maxLength`, `values` for an
|
|
32
|
+
enum, `reference` as `<entityId>` or `<moduleId>.<entityId>`), and optional
|
|
33
|
+
`states` (`field`, `values`, `transitions`).
|
|
34
|
+
- `screens[]`: `id`, `kind` of `list`, `record`, `form` or `dashboard`,
|
|
35
|
+
`entity`, `title`, `columns`, `filters`, `navigationGroup`.
|
|
36
|
+
- `actions[]`: `id`, `entity`, `permission`, `kind`, `risk`, `idempotent`,
|
|
37
|
+
`description`. `widgets[]`: `id`, `slot`, `entity`, `description`.
|
|
38
|
+
- `settings[]`: `key`, `type`, `scope`, `default`, `values`, `description`.
|
|
39
|
+
`agentTools[]`: `id`, `permission`, `description`, `risk`.
|
|
40
|
+
- `outOfScope[]` records what is deliberately not built; `decisions[]` records
|
|
41
|
+
each interview question, its answer and whether a user or a default decided.
|
|
42
|
+
|
|
43
|
+
Validation is more than the schema: an action permission must exist in
|
|
44
|
+
`permissions`, every `entity` must name an entity, screen `columns` and
|
|
45
|
+
`filters` must be fields of that entity, a `reference` must resolve in this spec
|
|
46
|
+
or a declared dependency, an enum field or setting needs `values`, `states.field`
|
|
47
|
+
must be the enum that declares exactly `states.values`, a field may not be called
|
|
48
|
+
`id`, `tenantId`, `createdAt` or a PostgreSQL reserved word such as `order` or
|
|
49
|
+
`user` (generated SQL quotes no identifier), and the `database` capability needs
|
|
50
|
+
at least one entity (`SPEC_ACTION_PERMISSION_UNKNOWN`, `SPEC_ENTITY_UNKNOWN`,
|
|
51
|
+
`SPEC_FIELD_UNKNOWN`, `SPEC_FIELD_RESERVED`, `SPEC_STATE_FIELD_INVALID`,
|
|
52
|
+
`SPEC_REFERENCE_UNKNOWN`, `SPEC_ENUM_VALUES_REQUIRED`, `SPEC_ENTITY_REQUIRED`,
|
|
53
|
+
`SPEC_DUPLICATE_ID`). A client without a list screen, or a stored entity with no
|
|
54
|
+
tenant-unique field, is a warning.
|
|
55
|
+
|
|
25
56
|
### 2. Scaffold
|
|
26
57
|
|
|
27
58
|
```bash
|
|
@@ -42,6 +73,15 @@ and `migrations/0001_*.up.sql` and `.down.sql` with the tenant table and its
|
|
|
42
73
|
forced row-level security block. The contract is in
|
|
43
74
|
[Database adapters](database-adapters.md) and the `database-adapter` skill.
|
|
44
75
|
|
|
76
|
+
From a `schemaVersion: 2` spec one entity replaces the demo record: the one
|
|
77
|
+
whose id matches the permission entity segment, else the first declared. Its
|
|
78
|
+
fields become `src/domain/types.ts`, the `0001` migration columns (with
|
|
79
|
+
`UNIQUE (tenant_id, …)` for a `unique: tenant` field), the repository row
|
|
80
|
+
mapping, the create endpoint's input validation (required fields only; the
|
|
81
|
+
lifecycle field is set by the service), and the columns of the first `list`
|
|
82
|
+
screen become the table view. Further entities are the implementing agent's
|
|
83
|
+
work.
|
|
84
|
+
|
|
45
85
|
Files are written through the workspace Prettier, so the format gate passes
|
|
46
86
|
without a rewrite. A directory that already holds `spec/module.yaml` or
|
|
47
87
|
`translations/**` is extended, not rejected, and a failed run leaves nothing
|
|
@@ -107,6 +147,38 @@ A running `pnpm dev` picks the change up live: the octane plugin reloads server
|
|
|
107
147
|
routes when the generated composition changes and the client hot-reloads, so no
|
|
108
148
|
rebuild is needed. `pnpm dev` and `pnpm build` run the sync automatically.
|
|
109
149
|
|
|
150
|
+
## Versions, ranges and capabilities
|
|
151
|
+
|
|
152
|
+
A module carries one version in three places: `module.json` `version`,
|
|
153
|
+
`package.json` `version` and `spec/module.yaml` `specVersion`. Bump them with
|
|
154
|
+
one command; it also rewrites every dependent range that stops accepting the
|
|
155
|
+
new version and keeps the operator (`^0.11.0` becomes `^0.12.0`):
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
pnpm flowdular module version auth.core
|
|
159
|
+
pnpm flowdular module version bump auth.core minor --apply
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Dependency ranges use caret (`^0.11.0`). Below 1.0.0 a caret range accepts
|
|
163
|
+
patch releases only, so a minor bump of a core module still moves its
|
|
164
|
+
dependents, which the bump command does. `module validate` reports
|
|
165
|
+
`SPEC_DEPENDENCY_DRIFT` when the spec and manifest ranges differ and
|
|
166
|
+
`PLATFORM_API_MISSING` when a manifest lacks `platformApi`.
|
|
167
|
+
|
|
168
|
+
`platformApi` is the range of the platform contract the module compiles against
|
|
169
|
+
(`^0.1.0`). The contract surface is pinned in
|
|
170
|
+
`packages/kernel/platform-api.snapshot.d.ts`; a change to it without a
|
|
171
|
+
`PLATFORM_API_VERSION` bump fails `pnpm verify`. `module search --compatible`
|
|
172
|
+
lists only releases whose range accepts the running platform.
|
|
173
|
+
|
|
174
|
+
Cross-module services are declared by capability id, not by module version:
|
|
175
|
+
`provides` lists the ids a module registers, `requires` lists the ids it
|
|
176
|
+
resolves (`optional: true` when it handles absence). The kernel orders required
|
|
177
|
+
providers before consumers, refuses a missing provider or two providers of one
|
|
178
|
+
id, and the composition hands each module a registry view that accepts only its
|
|
179
|
+
declared ids. The installer resolves a required capability to the newest
|
|
180
|
+
compatible release that provides it.
|
|
181
|
+
|
|
110
182
|
## Files the CLI owns
|
|
111
183
|
|
|
112
184
|
Never edit these by hand:
|
|
@@ -128,6 +200,150 @@ returned as `settings` from the composition, read live with
|
|
|
128
200
|
module's drawer under Administration, Modules. Administration, Settings holds
|
|
129
201
|
only workspace and organization settings.
|
|
130
202
|
|
|
203
|
+
## Record policies
|
|
204
|
+
|
|
205
|
+
A permission says who may act on a kind of record. A policy says whether this
|
|
206
|
+
record allows it: an amount above an approver's limit, a claim the submitter
|
|
207
|
+
owns, a contract outside the reviewer's region. Policies are declared with
|
|
208
|
+
`definePolicy` from `@flowdular/sdk/kernel` and evaluated through a registry the
|
|
209
|
+
module fills while it composes:
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const amountLimit = definePolicy<Claim>({
|
|
213
|
+
id: 'expenses.claims.amount-limit',
|
|
214
|
+
permission: 'expenses.claims.approve',
|
|
215
|
+
evaluate: ({ record }) =>
|
|
216
|
+
record.amountMinor <= 100_000
|
|
217
|
+
? { allowed: true }
|
|
218
|
+
: {
|
|
219
|
+
allowed: false,
|
|
220
|
+
reason: 'The claim exceeds the approver limit.',
|
|
221
|
+
requiresApproval: {
|
|
222
|
+
requirement: { roleKey: 'finance-lead', decisions: 2 },
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const policies = createPolicyRegistry();
|
|
228
|
+
policies.register(amountLimit);
|
|
229
|
+
policies.seal();
|
|
230
|
+
|
|
231
|
+
/* In the endpoint, once the record is loaded and before it is written. */
|
|
232
|
+
const decision = authorizeRecord(
|
|
233
|
+
policies,
|
|
234
|
+
principal,
|
|
235
|
+
'expenses.claims.approve',
|
|
236
|
+
claim,
|
|
237
|
+
'approve',
|
|
238
|
+
);
|
|
239
|
+
if (!decision.allowed) {
|
|
240
|
+
throw new HttpProblem('POLICY_DENIED', decision.reason, 403);
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`authorizeRecord` checks the permission scope first and evaluates policies only
|
|
245
|
+
after it holds, so a policy never sees a principal that lacks the permission. It
|
|
246
|
+
answers the first denial in registration order; a permission with no policy is
|
|
247
|
+
allowed by the scope alone. A policy that throws denies rather than escaping
|
|
248
|
+
into the endpoint. Register once per id, at most 256 policies, and seal the
|
|
249
|
+
registry before serving requests: a policy registered at request time would
|
|
250
|
+
change a decision under the requests already in flight.
|
|
251
|
+
|
|
252
|
+
`requiresApproval.requirement` carries `roleKey`, `scope`, `decisions` and
|
|
253
|
+
`expiresInDays`, and nothing else. Resolving a role to people, collecting their
|
|
254
|
+
decisions and keeping the receipt belong to the module that owns approvals; the
|
|
255
|
+
kernel owns the shape so a denial can name what would lift it. The platform does
|
|
256
|
+
not compose a shared registry yet, so a module that wants record conditions today
|
|
257
|
+
owns its registry inside its own composition.
|
|
258
|
+
|
|
259
|
+
## List pagination
|
|
260
|
+
|
|
261
|
+
New list endpoints page with the helpers in `@flowdular/sdk/server`
|
|
262
|
+
(`packages/server/src/pagination.ts`). The endpoints written before them keep
|
|
263
|
+
their own paging until the owning module retrofits it in its own change, so do
|
|
264
|
+
not migrate another module's list while touching yours.
|
|
265
|
+
|
|
266
|
+
```ts
|
|
267
|
+
const page = readPageQuery(new URL(octane.request.url), { maxLimit: 100 });
|
|
268
|
+
const after = page.cursor ? decodeCursor(page.cursor, cursorSecret) : null;
|
|
269
|
+
const keyset = after
|
|
270
|
+
? keysetWhere(
|
|
271
|
+
['created_at', 'id'],
|
|
272
|
+
[Number(after['createdAt']), String(after['id'])],
|
|
273
|
+
{ parameterOffset: 1 },
|
|
274
|
+
)
|
|
275
|
+
: null;
|
|
276
|
+
|
|
277
|
+
/* One row more than the page is what says whether another page exists. */
|
|
278
|
+
const rows = await repository.pageClaims(tenantId, keyset, page.limit + 1);
|
|
279
|
+
const items = rows.slice(0, page.limit);
|
|
280
|
+
const last = items[items.length - 1];
|
|
281
|
+
return pageResponse({
|
|
282
|
+
items,
|
|
283
|
+
limit: page.limit,
|
|
284
|
+
nextCursor:
|
|
285
|
+
rows.length > page.limit && last
|
|
286
|
+
? encodeCursor({ createdAt: last.createdAt, id: last.id }, cursorSecret)
|
|
287
|
+
: null,
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
`readPageQuery` answers `{ limit, cursor }`, defaults to 50 rows, and refuses
|
|
292
|
+
bad input the way the rest of the input boundary does: 400 `INVALID_INPUT` for a
|
|
293
|
+
limit outside 1 to the endpoint's `maxLimit`, 400 `CURSOR_INVALID` for a cursor
|
|
294
|
+
that is too long or not cursor-shaped. The platform ceiling is 200 rows; an
|
|
295
|
+
endpoint narrows it with `maxLimit` and picks its own `defaultLimit`.
|
|
296
|
+
|
|
297
|
+
A cursor is the keyset of the last row of the page, HMAC signed with a 32 byte
|
|
298
|
+
secret the module owns and bounded to 1 KB, so a caller cannot move the page
|
|
299
|
+
boundary onto a row the query excludes. `decodeCursor` answers the same
|
|
300
|
+
`CURSOR_INVALID` for a forged, edited or retired cursor, which a client treats
|
|
301
|
+
as "start from the first page". Put nothing in a cursor but the ordering keys:
|
|
302
|
+
it is a position, not saved state.
|
|
303
|
+
|
|
304
|
+
The response body is `{ items, page: { nextCursor, limit, total? } }`.
|
|
305
|
+
`nextCursor` is null on the last page, and `total` belongs in the body only
|
|
306
|
+
where the count is cheap, which a count over a tenant's rows usually is not.
|
|
307
|
+
`keysetWhere(columns, cursorValues, { direction, parameterOffset })` builds
|
|
308
|
+
`(created_at < $2 OR (created_at = $2 AND id < $3))` with the values bound and
|
|
309
|
+
the column names checked as plain identifiers; the statement's `ORDER BY` must
|
|
310
|
+
match the columns and direction, ending in a unique column, and the table needs
|
|
311
|
+
an index on them. Offset paging is not a platform capability: `OFFSET` over a
|
|
312
|
+
tenant's rows gets slower with every page and drops rows that move.
|
|
313
|
+
Record history keeps its own narrower request contract, `parseHistoryRequest`
|
|
314
|
+
from `@flowdular/sdk/kernel`.
|
|
315
|
+
|
|
316
|
+
## Search providers
|
|
317
|
+
|
|
318
|
+
A module that owns searchable records registers a provider with `search.core`
|
|
319
|
+
instead of feeding a central index. Declare `search.core` under `dependencies`
|
|
320
|
+
so it composes first, add `{ id: "search.providers.v1", optional: true }` under
|
|
321
|
+
`requires`, and register in `createServerComposition`:
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
context.capabilities
|
|
325
|
+
.get<SearchProviderRegistry>(SEARCH_PROVIDERS_CAPABILITY)
|
|
326
|
+
?.register('users.core', [createMemberSearchProvider(context.auth)]);
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
A provider is `{ key, label, permission, search(input) }`. `search` receives
|
|
330
|
+
`{ tenantId, principal, query, limit, cursor?, signal? }` and answers
|
|
331
|
+
`{ hits, nextCursor }`, where a hit carries only `ref`, `title`, `snippet`,
|
|
332
|
+
`viewId`, `route` and `score`: search.core never opens the provider's tables and
|
|
333
|
+
never fetches the record. The `route` is workspace-relative and starts with `/`;
|
|
334
|
+
`score` orders hits inside one provider and is never compared across providers.
|
|
335
|
+
The query is normalized and bounded to 200 characters before a provider sees it,
|
|
336
|
+
and a query under two characters reaches nobody.
|
|
337
|
+
|
|
338
|
+
`search.core` runs every provider whose `permission` the principal holds in
|
|
339
|
+
parallel under `providerTimeoutMs`, merges provider-major, and pages the merged
|
|
340
|
+
stream with the platform cursor helpers. A provider that fails, times out, or
|
|
341
|
+
answers something unreadable contributes nothing and is named in the response's
|
|
342
|
+
`unavailable`; it never turns the member's search into an error. The registry is
|
|
343
|
+
sealed when search.core starts, so registration happens at composition and never
|
|
344
|
+
at request time. The capability stays optional: resolve it with `get`, register
|
|
345
|
+
behind `?.`, and the module still composes where search.core is not enabled.
|
|
346
|
+
|
|
131
347
|
## Adding a CLI command
|
|
132
348
|
|
|
133
349
|
Module commands live in `src/cli/commands.json` and `src/cli/index.ts`,
|