@starci/hfs 4.9.6 → 4.9.9

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +24 -0
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
  5. package/scaffold/add-fe-layout.mjs +46 -0
  6. package/scaffold/add-fe-overlay.mjs +108 -0
  7. package/scaffold/add-fe-page.mjs +137 -0
  8. package/scaffold/add-fe-unit.mjs +288 -0
  9. package/scaffold/add.mjs +20 -1
  10. package/scaffold/app.mjs +3 -1
  11. package/src/main.mjs +11 -5
  12. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +41 -0
  13. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +14 -2
  14. package/templates/be/skeleton/apps/cli/src/compose/platform.ts +3 -2
  15. package/templates/be/skeleton/src/modules/platform/http-security/index.ts +1 -0
  16. package/templates/be/skeleton/src/modules/platform/http-security/persistence/connection.ts +8 -0
  17. package/templates/be/skeleton/src/modules/platform/http-security/persistence/entities/rate-window.entity.ts +17 -0
  18. package/templates/be/skeleton/src/modules/platform/http-security/persistence/migrations/1789900000000-create-rate-windows.ts +24 -0
  19. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.rows.ts +5 -0
  20. package/templates/be/skeleton/src/modules/platform/http-security/persistence/rate-limit.sql.ts +20 -0
  21. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.spec.ts +72 -46
  22. package/templates/be/skeleton/src/modules/platform/http-security/rate-limit.guard.ts +20 -25
  23. package/templates/be/skeleton-lite/src/modules/platform/database/errors/database.error.ts +3 -0
  24. package/templates/be/skeleton-lite/src/modules/platform/database/index.ts +2 -1
  25. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +7 -5
  26. package/templates/fe/skeleton/apps/landing/src/features/pages/LandingPage/component.tsx +7 -6
  27. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +4 -5
  28. package/templates/fe/units/block/component.tsx.tpl +35 -0
  29. package/templates/fe/units/block/index.tsx.tpl +29 -0
  30. package/templates/fe/units/block/stories.tsx.tpl +29 -0
  31. package/templates/fe/units/branch/index.tsx.tpl +23 -0
  32. package/templates/fe/units/composite/index.tsx.tpl +18 -0
  33. package/templates/fe/units/layout/index.tsx.tpl +11 -0
  34. package/templates/fe/units/layout/route-layout.tsx.tpl +9 -0
  35. package/templates/fe/units/leaf/index.tsx.tpl +10 -0
  36. package/templates/fe/units/overlay/component.tsx.tpl +27 -0
  37. package/templates/fe/units/overlay/index.tsx.tpl +23 -0
  38. package/templates/fe/units/overlay/stories.tsx.tpl +29 -0
  39. package/templates/fe/units/page/component.tsx.tpl +35 -0
  40. package/templates/fe/units/page/error-component.tsx.tpl +18 -0
  41. package/templates/fe/units/page/error-index.tsx.tpl +11 -0
  42. package/templates/fe/units/page/index.tsx.tpl +29 -0
  43. package/templates/fe/units/page/route-error.tsx.tpl +10 -0
  44. package/templates/fe/units/page/route-loading.tsx.tpl +6 -0
  45. package/templates/fe/units/page/route-page.tsx.tpl +9 -0
  46. package/templates/fe/units/page/stories.tsx.tpl +34 -0
  47. package/templates/fe/units/slot/SlotView.tsx.tpl +28 -0
  48. package/templates/fe/units/slot/data-slot.ts.tpl +18 -0
  49. package/templates/fe/units/slot/slot.shared.ts.tpl +35 -0
  50. package/templates/fe/units/slot/useSlotLabels.ts.tpl +8 -0
@@ -0,0 +1,288 @@
1
+ // starci app add <block|composite|branch|leaf> <name> - one front-end unit, already in the shape-slot pattern.
2
+ //
3
+ // The bodies are templates/fe/units/<tier>/; this module only fills them and places them (contexts/code/fe function.md
4
+ // FE-FUNCTION-2..4 and FE-FUNCTION-7, README.md FE-ERROR-1).
5
+ //
6
+ // starci app add block handoff-block HandoffBlock: one read folded into a DataSlot, no shape
7
+ // starci app add block handoff-block --shapes prepared,sent,returned plus HANDOFF_BLOCK_SHAPES, HandoffBlockShape and an exhaustive switch
8
+ // starci app add composite term-value | branch disclosure | leaf status-label
9
+ //
10
+ // A block reads `useQuery<Read>Swr` of hooks/swr (`--read`, default: the block name without its `Block` suffix; `add table --fe`
11
+ // writes that hook). A block with shapes takes its shape from the read's `status` (the placeholder's own while the read loads), so
12
+ // the read names its shape in that field. The first block of an app also writes the app's one copy of the status owners
13
+ // (FE-ERROR-1): DataSlot and SlotLabels in modules/types, slotOfSwr/mapSlot/loadingSlot in hooks/slot/slot.shared.ts,
14
+ // useSlotLabels, the SlotView composite and the shared `status` namespace of every catalog. Nothing that exists is overwritten.
15
+ // A composite, branch or leaf, SlotView among them, is vocabulary that knows no feature: in a front end of two or more apps with
16
+ // the shared ui package (slot fe.package.ui) it lives in that package and its barrel, DataSlot and SlotLabels are declared beside
17
+ // SlotView there and modules/types re-exports them (R91 `monorepo-tier-belongs-to-its-side`); else it lives in the app's components.
18
+ // A shape that names a data status is refused by the lint (R92 `no-data-status-shape`), which owns that word list.
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { ScaffoldError, camelOf, pascalOf } from './service.mjs';
23
+ import { coreFeApps } from './app.mjs';
24
+
25
+ const UNITS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'templates', 'fe', 'units');
26
+
27
+ /** The nouns this module answers, and the components layer each one writes into. */
28
+ export const FE_UNIT_LAYERS = Object.freeze({ block: 'blocks', composite: 'composites', branch: 'branches', leaf: 'leaves' });
29
+
30
+ /** The shared status copy of FE-ERROR-1, by catalog locale; any other locale takes the English sentences. */
31
+ const STATUS_COPY = Object.freeze({
32
+ en: { forbidden: 'You do not have access to this.', error: 'This could not be loaded.', retry: 'Try again' },
33
+ vi: { forbidden: 'Bạn không có quyền xem phần này.', error: 'Không tải được phần này.', retry: 'Thử lại' },
34
+ });
35
+ /** The empty sentence of a new block, by catalog locale. */
36
+ const EMPTY_COPY = Object.freeze({ en: 'Nothing to show yet.', vi: 'Chưa có gì để hiển thị.' });
37
+
38
+ export const words = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[\s_-]+/).filter(Boolean).map((word) => word.toLowerCase());
39
+ export const humanOf = (name) => { const text = words(name).join(' '); return text[0].toUpperCase() + text.slice(1); };
40
+
41
+ /** A template body with its `{{name}}` variables filled; a variable without a value is a template error, never left in place. */
42
+ export function fill(file, vars) {
43
+ const body = fs.readFileSync(path.join(UNITS_DIR, ...file.split('/')), 'utf8').replace(/\r\n/g, '\n');
44
+ return body.replace(/\{\{(\w+)\}\}/g, (_, key) => {
45
+ if (!Object.hasOwn(vars, key)) throw new ScaffoldError('HFS_SCAFFOLD_TEMPLATE_VARIABLE', `templates/fe/units/${file} names {{${key}}}, which has no value`);
46
+ return vars[key];
47
+ });
48
+ }
49
+
50
+ /** The parsed hfs.json of the app root. */
51
+ export function declarationOf(repoRoot) {
52
+ const declarationFile = path.join(repoRoot, 'hfs.json');
53
+ if (!fs.existsSync(declarationFile)) throw new ScaffoldError('HFS_ADD_NOT_AN_APP', 'starci app add needs the app root hfs.json');
54
+ return JSON.parse(fs.readFileSync(declarationFile, 'utf8'));
55
+ }
56
+
57
+ /** The one product app the unit belongs to: `--app`, else the only product Next app of hfs.json. */
58
+ export function appOf(declaration, requested) {
59
+ const apps = coreFeApps(declaration.sides?.fe?.apps ?? []);
60
+ if (requested !== undefined) {
61
+ if (!apps.some((entry) => entry.name === requested)) throw new ScaffoldError('HFS_ADD_NO_FRONT_END', `${requested} is not a product Next app of hfs.json`);
62
+ return requested;
63
+ }
64
+ if (apps.length !== 1) throw new ScaffoldError('HFS_ADD_NO_FRONT_END', `a front-end unit names its app with --app when hfs.json declares ${apps.length} product Next apps`);
65
+ return apps[0].name;
66
+ }
67
+
68
+ /**
69
+ * Where the vocabulary tiers go: the shared ui package (`fe/packages/<project>-ui`: its folder and its import name) when the front
70
+ * end has two or more apps and the package exists, else null (the app's own components).
71
+ */
72
+ function sharedUiOf(repoRoot, declaration) {
73
+ if ((declaration.sides?.fe?.apps ?? []).length < 2) return null;
74
+ const relative = `fe/packages/${declaration.project}-ui`;
75
+ const manifest = path.join(repoRoot, ...relative.split('/'), 'package.json');
76
+ if (!fs.existsSync(manifest)) return null;
77
+ return { relative, name: JSON.parse(fs.readFileSync(manifest, 'utf8')).name };
78
+ }
79
+
80
+ /** The shape members of `--shapes a,b,c`: none, or two or more distinct camelCase words, in the order given. */
81
+ function shapesOf(option) {
82
+ if (option === undefined || option === false) return [];
83
+ const shapes = (Array.isArray(option) ? option : String(option).split(',')).map((value) => value.trim()).filter(Boolean);
84
+ if (shapes.length < 2) throw new ScaffoldError('HFS_ADD_SHAPES_INVALID', '--shapes names two or more drawn shapes; a unit with one shape takes none');
85
+ if (new Set(shapes).size !== shapes.length) throw new ScaffoldError('HFS_ADD_SHAPES_INVALID', `--shapes repeats a member: ${shapes.join(',')}`);
86
+ const unworded = shapes.find((shape) => !/^[a-z][a-zA-Z0-9]*$/.test(shape));
87
+ if (unworded !== undefined) throw new ScaffoldError('HFS_ADD_SHAPES_INVALID', `the shape "${unworded}" is not a camelCase word`);
88
+ return shapes;
89
+ }
90
+
91
+ /** Writes one file the unit owns (`relative` to the repository root); the unit's own files must not exist yet. */
92
+ export function writeNew(created, repoRoot, relative, body) {
93
+ const file = path.join(repoRoot, ...relative.split('/'));
94
+ if (fs.existsSync(file)) throw new ScaffoldError('HFS_ADD_EXISTS', `${relative} already exists; starci app add never overwrites a unit`);
95
+ fs.mkdirSync(path.dirname(file), { recursive: true });
96
+ fs.writeFileSync(file, body);
97
+ created.push(relative);
98
+ }
99
+
100
+ /** Writes one shared file only when it is missing (the status owners have one copy per app). */
101
+ function writeOnce(created, repoRoot, relative, body) {
102
+ const file = path.join(repoRoot, ...relative.split('/'));
103
+ if (fs.existsSync(file)) return;
104
+ fs.mkdirSync(path.dirname(file), { recursive: true });
105
+ fs.writeFileSync(file, body);
106
+ created.push(relative);
107
+ }
108
+
109
+ /** Adds `line` to a barrel, creating it, unless the barrel already has it. */
110
+ export function exportFrom(created, repoRoot, relative, line) {
111
+ const file = path.join(repoRoot, ...relative.split('/'));
112
+ const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
113
+ if (before.split(/\r?\n/).includes(line)) return;
114
+ fs.mkdirSync(path.dirname(file), { recursive: true });
115
+ fs.writeFileSync(file, `${before}${before === '' || before.endsWith('\n') ? '' : '\n'}${line}\n`);
116
+ if (!created.includes(relative)) created.push(relative);
117
+ }
118
+
119
+ /** The app's one copy of the FE-ERROR-1 owners, written by the first block that needs them; SlotView is shared vocabulary. */
120
+ function ensureSlotOwners(created, { repoRoot, appBase, ui }) {
121
+ const types = fill('slot/data-slot.ts.tpl', {});
122
+ if (ui === null) {
123
+ writeOnce(created, repoRoot, `${appBase}/src/modules/types/data-slot.ts`, types);
124
+ exportFrom(created, repoRoot, `${appBase}/src/modules/types/index.ts`, 'export type { DataSlot, SlotLabels } from "./data-slot"');
125
+ writeOnce(created, repoRoot, `${appBase}/src/components/composites/SlotView/index.tsx`, fill('slot/SlotView.tsx.tpl', { slotTypes: 'import type { DataSlot, SlotLabels } from "@/modules/types"\n' }));
126
+ } else {
127
+ writeOnce(created, repoRoot, `${ui.relative}/src/composites/SlotView/index.tsx`, fill('slot/SlotView.tsx.tpl', { slotTypes: `\n${types}` }));
128
+ exportFrom(created, repoRoot, `${ui.relative}/src/index.ts`, 'export { SlotView } from "./composites/SlotView"');
129
+ exportFrom(created, repoRoot, `${ui.relative}/src/index.ts`, 'export type { DataSlot, SlotLabels } from "./composites/SlotView"');
130
+ exportFrom(created, repoRoot, `${appBase}/src/modules/types/index.ts`, `export type { DataSlot, SlotLabels } from "${ui.name}"`);
131
+ }
132
+ writeOnce(created, repoRoot, `${appBase}/src/hooks/slot/slot.shared.ts`, fill('slot/slot.shared.ts.tpl', {}));
133
+ writeOnce(created, repoRoot, `${appBase}/src/hooks/slot/useSlotLabels.ts`, fill('slot/useSlotLabels.ts.tpl', {}));
134
+ exportFrom(created, repoRoot, `${appBase}/src/hooks/slot/index.ts`, 'export { loadingSlot, mapSlot, slotOfSwr } from "./slot.shared"');
135
+ exportFrom(created, repoRoot, `${appBase}/src/hooks/slot/index.ts`, 'export { useSlotLabels } from "./useSlotLabels"');
136
+ }
137
+
138
+ /**
139
+ * Adds namespaces to every catalog of the app, keeping what is there: `namespacesOf(locale)` gives `{ namespace: copy }`, and a
140
+ * namespace the catalog already has is left as it is.
141
+ */
142
+ export function addCatalogNamespaces(created, { repoRoot, appBase }, namespacesOf) {
143
+ const dir = path.join(repoRoot, ...appBase.split('/'), 'src', 'modules', 'i18n', 'messages');
144
+ if (!fs.existsSync(dir)) throw new ScaffoldError('HFS_ADD_WIRE_MISSING', 'a front-end unit reads its copy from modules/i18n/messages, which does not exist');
145
+ for (const entry of fs.readdirSync(dir).filter((file) => file.endsWith('.json')).sort()) {
146
+ const file = path.join(dir, entry);
147
+ const catalog = JSON.parse(fs.readFileSync(file, 'utf8'));
148
+ const before = JSON.stringify(catalog);
149
+ for (const [namespace, copy] of Object.entries(namespacesOf(entry.slice(0, -'.json'.length)))) catalog[namespace] ??= copy;
150
+ if (JSON.stringify(catalog) === before) continue;
151
+ fs.writeFileSync(file, `${JSON.stringify(catalog, null, 2)}\n`);
152
+ created.push(`${appBase}/src/modules/i18n/messages/${entry}`);
153
+ }
154
+ }
155
+
156
+ /** The shared `status` namespace and the block's own namespace. */
157
+ function addCatalogKeys(created, place, { nameCamel, title, shapes }) {
158
+ addCatalogNamespaces(created, place, (locale) => ({
159
+ status: { ...(STATUS_COPY[locale] ?? STATUS_COPY.en) },
160
+ [nameCamel]: {
161
+ title,
162
+ empty: EMPTY_COPY[locale] ?? EMPTY_COPY.en,
163
+ ...(shapes.length ? { shapes: Object.fromEntries(shapes.map((shape) => [shape, humanOf(shape)])) } : {}),
164
+ },
165
+ }));
166
+ }
167
+
168
+ /** The pure half's shape inventory, union and exhaustive switch (FE-FUNCTION-7): one case per member, in the given order. */
169
+ function shapeParts({ Name, NAME_UPPER, shapes }) {
170
+ const cases = shapes.map((shape) => [
171
+ ` case "${shape}":`,
172
+ ' return (',
173
+ ' <>',
174
+ ` <Text isSkeleton={isSkeleton}>{shapeLabels.${shape}}</Text>`,
175
+ ' <Text isSkeleton={isSkeleton}>{item.id}</Text>',
176
+ ' </>',
177
+ ' )',
178
+ ].join('\n')).join('\n');
179
+ return {
180
+ shapeDeclarations: [
181
+ '',
182
+ `/** The drawn shapes of ${Name}, in the order the owner approved them: one drawing and one story each, never a data status. */`,
183
+ `export const ${NAME_UPPER}_SHAPES = [${shapes.map((shape) => `"${shape}"`).join(', ')}] as const`,
184
+ '',
185
+ `/** One drawn shape of ${Name}. */`,
186
+ `export type ${Name}Shape = (typeof ${NAME_UPPER}_SHAPES)[number]`,
187
+ '',
188
+ ].join('\n'),
189
+ shapeBody: [
190
+ '',
191
+ `/** The ready tree of one drawn shape: one case per member of ${Name}Shape, so a member nobody draws fails the lint. */`,
192
+ `const readyTreeOf = (shape: ${Name}Shape, shapeLabels: ${Name}BaseProps["props"]["shapeLabels"], item: ${Name}Item, isSkeleton: boolean) => {`,
193
+ ' switch (shape) {',
194
+ cases,
195
+ ' }',
196
+ '}',
197
+ '',
198
+ ].join('\n'),
199
+ shapeOfHelper: `\n/** The drawn shape a read's \`status\` names; a status no shape draws takes the placeholder's. */\nconst shapeOf = (status: string): ${Name}Shape => ${NAME_UPPER}_SHAPES.find((shape) => shape === status) ?? ${NAME_UPPER}_PLACEHOLDER.shape\n`,
200
+ };
201
+ }
202
+
203
+ /** The variables of the block templates; the shape parts are empty when the block has one shape. */
204
+ function blockVars({ Name, nameCamel, nameKebab, Read, shapes, title, ui }) {
205
+ const NAME_UPPER = words(Name).join('_').toUpperCase();
206
+ const shaped = shapes.length > 0;
207
+ const parts = shaped ? shapeParts({ Name, NAME_UPPER, shapes }) : { shapeDeclarations: '', shapeBody: '', shapeOfHelper: '' };
208
+ const story = (shape) => pascalOf(shape);
209
+ const readyStories = (shaped ? shapes : ['ready']).map((shape) => [
210
+ '',
211
+ `/** The ${shaped ? `${shape} shape` : 'ready read'}, settled. */`,
212
+ `export const ${story(shape)}: Story = {`,
213
+ ...(shaped ? [` args: { shape: "${shape}" },`] : []),
214
+ ` parameters: { dna: { pair: "${story(shape)}", phase: "ready" } },`,
215
+ '}',
216
+ ].join('\n')).join('\n');
217
+ return {
218
+ Name, nameCamel, nameKebab, Read, title, NAME_UPPER, ...parts,
219
+ slotViewFrom: ui === null ? '@/components/composites/SlotView' : ui.name,
220
+ itemShapeMember: shaped ? ` readonly shape: ${Name}Shape\n` : '',
221
+ shapeMember: shaped ? ` readonly shape: ${Name}Shape\n` : '',
222
+ shapeLabelsMember: shaped ? ` readonly shapeLabels: { ${shapes.map((shape) => `readonly ${shape}: string`).join('; ')} }\n` : '',
223
+ placeholderShapeDoc: shaped ? ', and its shape is the block\'s while the read loads' : '',
224
+ placeholderShape: shaped ? `, shape: "${shapes[0]}"` : '',
225
+ readyTree: shaped ? 'readyTreeOf(props.shape, props.props.shapeLabels, item, isSkeleton)' : '<Text isSkeleton={isSkeleton}>{item.id}</Text>',
226
+ componentImports: shaped ? `${NAME_UPPER}_PLACEHOLDER, ${NAME_UPPER}_SHAPES, ${Name}Base` : `${Name}Base`,
227
+ componentTypeImport: shaped ? `import type { ${Name}Shape } from "./component"\n` : '',
228
+ connectedDoc: shaped ? 'takes its shape from the read (the placeholder\'s while it loads), ' : '',
229
+ itemShapeMapped: shaped ? ', shape: shapeOf(value.status)' : '',
230
+ shapeAttribute: shaped ? ` shape={(item.items ?? ${NAME_UPPER}_PLACEHOLDER).shape}\n` : '',
231
+ shapeLabelsResolved: shaped ? ` shapeLabels: { ${shapes.map((shape) => `${shape}: t("shapes.${shape}")`).join(', ')} },\n` : '',
232
+ shapeLabelsFixture: shaped ? ` shapeLabels: { ${shapes.map((shape) => `${shape}: "${humanOf(shape)}"`).join(', ')} },\n` : '',
233
+ fixtureShape: shaped ? `, shape: "${shapes[0]}"` : '',
234
+ shapeArg: shaped ? `shape: "${shapes[0]}", ` : '',
235
+ storiesDoc: shaped ? 'one story per drawn shape' : 'the settled read',
236
+ readyStories,
237
+ firstStory: story(shaped ? shapes[0] : 'ready'),
238
+ };
239
+ }
240
+
241
+ /** Writes one block: its two halves (the pure one with its shapes, when it has them), its stories and its copy. */
242
+ function addBlock({ created, place, names, options }) {
243
+ const shapes = shapesOf(options.shapes);
244
+ const nameWords = words(names.Name);
245
+ const read = options.read ?? (nameWords.at(-1) === 'block' && nameWords.length > 1 ? nameWords.slice(0, -1).join('-') : names.nameKebab);
246
+ const vars = blockVars({ ...names, Read: pascalOf(read), shapes, title: humanOf(names.Name), ui: place.ui });
247
+ const folder = `${place.appBase}/src/components/blocks/${names.Name}`;
248
+ const stories = `${place.appBase}/stories/blocks/${names.Name}.stories.tsx`;
249
+ for (const relative of [folder, stories]) {
250
+ if (fs.existsSync(path.join(place.repoRoot, ...relative.split('/')))) throw new ScaffoldError('HFS_ADD_EXISTS', `${relative} already exists; starci app add never overwrites a unit`);
251
+ }
252
+ ensureSlotOwners(created, place);
253
+ writeNew(created, place.repoRoot, `${folder}/index.tsx`, fill('block/index.tsx.tpl', vars));
254
+ writeNew(created, place.repoRoot, `${folder}/component.tsx`, fill('block/component.tsx.tpl', vars));
255
+ writeNew(created, place.repoRoot, stories, fill('block/stories.tsx.tpl', vars));
256
+ addCatalogKeys(created, place, { nameCamel: names.nameCamel, title: vars.title, shapes });
257
+ }
258
+
259
+ /** Writes one composite, branch or leaf: into the shared ui package and its barrel, or into the app's components. */
260
+ function addVocabulary({ created, place, noun, Name }) {
261
+ const layer = FE_UNIT_LAYERS[noun];
262
+ const body = fill(`${noun}/index.tsx.tpl`, { Name });
263
+ if (place.ui === null) {
264
+ writeNew(created, place.repoRoot, `${place.appBase}/src/components/${layer}/${Name}/index.tsx`, body);
265
+ return;
266
+ }
267
+ writeNew(created, place.repoRoot, `${place.ui.relative}/src/${layer}/${Name}/index.tsx`, body);
268
+ exportFrom(created, place.repoRoot, `${place.ui.relative}/src/index.ts`, `export { ${Name} } from "./${layer}/${Name}"`);
269
+ }
270
+
271
+ /**
272
+ * Generates one front-end unit of the shape-slot pattern in the app at `repoRoot`.
273
+ *
274
+ * @param {{ repoRoot: string, noun: string, name: string, options?: { app?: string, shapes?: string | string[], read?: string } }} input - The app root, the tier noun, the unit name (kebab or PascalCase) and its options.
275
+ * @returns {{ created: string[] }} The files written or extended, relative to the app root (the folder of hfs.json).
276
+ */
277
+ export function addFeUnit({ repoRoot, noun, name, options = {} }) {
278
+ if (!Object.hasOwn(FE_UNIT_LAYERS, noun)) throw new ScaffoldError('HFS_ADD_UNKNOWN_NOUN', `${noun} is not a front-end unit tier`);
279
+ const Name = pascalOf(name);
280
+ if (!/^[A-Z][A-Za-z0-9]*$/.test(Name)) throw new ScaffoldError('HFS_ADD_NAME_INVALID', `${name} is not a unit name`);
281
+ if (noun !== 'block' && options.shapes !== undefined) throw new ScaffoldError('HFS_ADD_SHAPES_INVALID', `a ${noun} has no shapes; a pure half with shapes is a block's`);
282
+ const declaration = declarationOf(repoRoot);
283
+ const place = { repoRoot, appBase: `fe/apps/${appOf(declaration, options.app)}`, ui: sharedUiOf(repoRoot, declaration) };
284
+ const created = [];
285
+ if (noun === 'block') addBlock({ created, place, names: { Name, nameCamel: camelOf(Name), nameKebab: words(Name).join('-') }, options });
286
+ else addVocabulary({ created, place, noun, Name });
287
+ return { created };
288
+ }
package/scaffold/add.mjs CHANGED
@@ -11,6 +11,9 @@
11
11
  // starci app add reactor payment-status --event payment-settled --from payment --service PaymentStatusService=@modules/domain/order
12
12
  // starci app add queue receipt modules/queues/receipt: the typed producer
13
13
  // starci app add projection order-summary --connection order
14
+ // starci app add block handoff-block --shapes prepared,sent,returned a front-end unit in the shape-slot pattern (add-fe-unit.mjs)
15
+ // starci app add page handoffs | overlay invite-member --kind modal | layout settings a route page, overlay or segment layout
16
+ // (add-fe-page.mjs, add-fe-overlay.mjs, add-fe-layout.mjs)
14
17
  //
15
18
  // Body placeholders: for each variable `v` of the tree (job, step, event, ...) `@@v@@` is its kebab name, `@@V@@` its PascalCase and
16
19
  // `@@vCamel@@` its camelCase, `@@vUpper@@` its UPPER_SNAKE and `@@vSnake@@` its snake_case; the option `--service Class=module` gives `@@service@@` (the class) and `@@serviceModule@@`.
@@ -20,6 +23,10 @@ import { ScaffoldError, pascalOf } from './service.mjs';
20
23
  import { addTable, graphqlPlatformFiles } from './add-table.mjs';
21
24
  import { addModuleImport, compositionCode, readComposeGroup, writeComposeGroup } from './add-compose.mjs';
22
25
  import { addApp } from './add-app.mjs';
26
+ import { FE_UNIT_LAYERS, addFeUnit } from './add-fe-unit.mjs';
27
+ import { addFePage } from './add-fe-page.mjs';
28
+ import { addFeOverlay } from './add-fe-overlay.mjs';
29
+ import { addFeLayout } from './add-fe-layout.mjs';
23
30
  import { ensureLiteCli } from './add-cli-lite.mjs';
24
31
  import { addLiteWebhookInbox } from './add-inbox-lite.mjs';
25
32
  import { planLiteQueue } from './add-queue-lite.mjs';
@@ -30,10 +37,17 @@ import { bundledPins } from '../runtime/scripts/hfs/canon-pins.mjs';
30
37
  import { byCodeUnit } from '../report/order.mjs';
31
38
  import { registerLiteDomainService, registerLiteExports } from './lite-exports.mjs';
32
39
 
40
+ /** The front-end owner nouns besides the component tiers: each one's generator, given the app root, the name and the options. */
41
+ const FE_OWNERS = Object.freeze({
42
+ page: ({ repoRoot, name, options }) => addFePage({ repoRoot, route: name, options }),
43
+ overlay: addFeOverlay,
44
+ layout: ({ repoRoot, name, options }) => addFeLayout({ repoRoot, segment: name, options }),
45
+ });
46
+
33
47
  /**
34
48
  * Generates the tree of one noun in the app at `repoRoot` (the folder of hfs.json).
35
49
  *
36
- * @param {{ repoRoot: string, noun: string, name: string, options?: object, now?: () => number }} input - The app root, the noun (job, reactor, queue, projection), the instance name and the options the noun needs.
50
+ * @param {{ repoRoot: string, noun: string, name: string, options?: object, now?: () => number }} input - The app root, the noun (job, reactor, queue, projection, ..., or a front-end unit: block, composite, branch, leaf, page, overlay, layout), the instance name and the options the noun needs.
37
51
  * @returns {{ created: Array<string>, registered: { patterns: Array<string>, kinds: Array<string> } }} The files written (app-root relative) and what hfs.json gained.
38
52
  */
39
53
  export function addKind({ repoRoot, noun, name, options = {}, now = Date.now }) {
@@ -45,6 +59,11 @@ export function addKind({ repoRoot, noun, name, options = {}, now = Date.now })
45
59
  const result = addApp({ root: repoRoot, name });
46
60
  return { ...result, registered: { patterns: [], kinds: [] } };
47
61
  }
62
+ const feOwner = FE_OWNERS[noun];
63
+ if (feOwner !== undefined || Object.hasOwn(FE_UNIT_LAYERS, noun)) {
64
+ const result = feOwner === undefined ? addFeUnit({ repoRoot, noun, name, options }) : feOwner({ repoRoot, name, options });
65
+ return { ...result, registered: { patterns: [], kinds: [] } };
66
+ }
48
67
  const context = readAddContext({ repoRoot, noun, name, options, now });
49
68
  const builtin = addBuiltinLiteCli(context);
50
69
  if (builtin) return builtin;
package/scaffold/app.mjs CHANGED
@@ -407,11 +407,12 @@ const declaresCliApp = app => app.sides.be.apps.some(entry => entry.kind === 'cl
407
407
 
408
408
  const LITE_APP_REUSED = new Set(['.editorconfig', '.gitattributes', '.nvmrc']);
409
409
  const LITE_DEFERRED_GENERATOR_FILES = new Set([
410
- 'src/modules/platform/database/database.sql.ts',
411
410
  'src/modules/platform/database/migration-runner.service.ts',
412
411
  'src/modules/platform/database/seed-runner.service.ts',
413
412
  'src/modules/platform/primitives/sequence.policy.ts',
414
413
  ]);
414
+ /** The TypeORM schema files of a platform capability: lite's schema authority is supabase/migrations (be.persistence lite overlay). */
415
+ const LITE_TYPEORM_SCHEMA = /^src\/modules\/platform\/[^/]+\/persistence\/(?:connection\.ts$|entities\/|migrations\/)/;
415
416
  const liteBaseSource = (rel) => !rel.startsWith('apps/cli/')
416
417
  && !rel.startsWith('src/features/cli/')
417
418
  && !rel.startsWith('src/modules/domain/note/')
@@ -419,6 +420,7 @@ const liteBaseSource = (rel) => !rel.startsWith('apps/cli/')
419
420
  && rel !== 'src/modules/platform/database/migrate-connections.client.ts'
420
421
  && rel !== 'src/modules/platform/database/seed-connections.client.ts'
421
422
  && !LITE_DEFERRED_GENERATOR_FILES.has(rel)
423
+ && !LITE_TYPEORM_SCHEMA.test(rel)
422
424
  && !rel.startsWith('src/tests/');
423
425
  /** The be files the lite overlay replaces: a base unit spec of such a file tests the full variant, so lite carries its own. */
424
426
  const liteOverlaid = (() => {
package/src/main.mjs CHANGED
@@ -55,6 +55,11 @@
55
55
  // exactly that kind's file tree, generated FROM the files: tree of its pattern topic (knowledge/patterns/be) with the
56
56
  // one template body of each entry (templates/be/patterns), plus the platform capabilities it needs when they are missing;
57
57
  // it registers the patterns and the trigger kind in hfs.json (sides.be.patterns, sides.be.kinds). Never overwrites a file (scaffold/add.mjs). In a lite app a noun whose slots lite does not have refuses: `add <noun>: full edition only`.
58
+ // starci app add <block|composite|branch|leaf> <name> [--shapes a,b,c] [--read <read>] [--app <fe app>] [--cwd <dir>]
59
+ // starci app add <page|layout> <route> [--blocks a,b] [--tabs a,b (page)] | overlay <name> --kind modal|drawer [--form] a route page, segment layout
60
+ // or overlay owner (scaffold/add-fe-page.mjs, add-fe-layout.mjs, add-fe-overlay.mjs).
61
+ // one front-end unit in the shape-slot pattern from templates/fe/units (scaffold/add-fe-unit.mjs); the
62
+ // first block writes the app's one copy of the status owners. Never overwrites a unit.
58
63
  // Every finding names a why code and carries its Vietnamese text. check, lint and explain read the app, never write to it. Exit codes:
59
64
  // 0 clean, 1 error findings, 2 a refusal or bad usage.
60
65
  import fs from 'node:fs';
@@ -97,7 +102,7 @@ export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check',
97
102
  const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
98
103
 
99
104
  const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
100
- starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app> <name> [options] [--cwd <dir>]
105
+ starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|block|composite|branch|leaf|page|overlay|layout> <name> [options] [--cwd <dir>]
101
106
  starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
102
107
  starci app codemod button-intent [--write] [--format json|markdown] [--report <file.json|file.md>] [--cwd <dir>]
103
108
  starci app sync (--check | --write) [--cwd <dir>]
@@ -136,10 +141,10 @@ starci app hygiene [--cwd <dir>]
136
141
  starci app coverage merge --out <lcov> <lcov-or-dir>... [--cwd <dir>]
137
142
  `;
138
143
  const PER_CODE_LIMIT = 25;
139
- const VALUE_FLAGS = new Set(['--base', '--inject', '--into', '--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--coverage-from', '--registry-visibility', '--shapes', '--read', '--app']);
144
+ const VALUE_FLAGS = new Set(['--base', '--inject', '--into', '--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--coverage-from', '--registry-visibility', '--shapes', '--read', '--app', '--kind', '--blocks', '--tabs']);
140
145
  /** Flags that may repeat: their values are collected in order. */
141
146
  const LIST_FLAGS = new Set(['--inject']);
142
- const BOOL_FLAGS = new Set(['--json', '--fast', '--fe', '--no-types', '--db-types', '--wiring']);
147
+ const BOOL_FLAGS = new Set(['--json', '--fast', '--fe', '--no-types', '--db-types', '--wiring', '--form']);
143
148
 
144
149
  function coverageArgument(arg, args, opts) {
145
150
  const inline = arg.startsWith('--coverage-from=');
@@ -390,10 +395,11 @@ function newMain(opts, { cwd: repoRoot, stdout: out }) {
390
395
  function addMain(opts, { cwd: repoRoot, stdout: out }) {
391
396
  const [noun, name, ...extra] = opts.positional;
392
397
  if (!noun || !name || extra.length) throw new Error('starci app add takes `<noun> <name>` and the options of the noun');
393
- const { created, registered } = addKind({ repoRoot, noun, name, options: { event: opts.event, from: opts.from, service: opts.service, connection: opts.connection, owner: opts.owner, failed: opts.failed, done: opts.done, fe: opts.fe, noTypes: opts['no-types'], shapes: opts.shapes, read: opts.read, app: opts.app } });
398
+ const { created, registered } = addKind({ repoRoot, noun, name, options: { event: opts.event, from: opts.from, service: opts.service, connection: opts.connection, owner: opts.owner, failed: opts.failed, done: opts.done, fe: opts.fe, noTypes: opts['no-types'], shapes: opts.shapes, read: opts.read, app: opts.app, kind: opts.kind, form: opts.form, blocks: opts.blocks, tabs: opts.tabs } });
394
399
  for (const file of created) out(`created ${file}\n`);
395
400
  const kinds = registered.kinds.length ? `; kinds ${registered.kinds.join(', ')}` : '';
396
- out(`starci app add ${noun}: registered patterns ${registered.patterns.join(', ')}${kinds}\n`);
401
+ // A noun that registers nothing in hfs.json (a table, an app, a front-end unit) ends with what it wrote.
402
+ if (registered.patterns.length || registered.kinds.length) out(`starci app add ${noun}: registered patterns ${registered.patterns.join(', ')}${kinds}\n`);
397
403
  return 0;
398
404
  }
399
405
 
@@ -62,3 +62,44 @@ grant usage on schema public to app_be;
62
62
  revoke all on table public.profiles from public, anon, authenticated;
63
63
  grant select, insert on table public.profiles to app_be;
64
64
  grant update (display_name) on table public.profiles to app_be;
65
+
66
+ -- The rate limit windows of platform/http-security: every api replica counts a caller's hits in the same row, and a hit
67
+ -- that opens a fresh window prunes a bounded batch of the ended ones.
68
+ create table public.http_security_rate_windows (
69
+ key varchar(128) primary key,
70
+ hits integer not null check (hits > 0),
71
+ reset_at timestamptz not null
72
+ );
73
+
74
+ create index http_security_rate_windows_reset_at_idx on public.http_security_rate_windows (reset_at);
75
+
76
+ alter table public.http_security_rate_windows enable row level security;
77
+ alter table public.http_security_rate_windows force row level security;
78
+
79
+ create policy http_security_rate_windows_app_be_select
80
+ on public.http_security_rate_windows
81
+ for select
82
+ to app_be
83
+ using (current_user = 'app_be');
84
+
85
+ create policy http_security_rate_windows_app_be_insert
86
+ on public.http_security_rate_windows
87
+ for insert
88
+ to app_be
89
+ with check (current_user = 'app_be');
90
+
91
+ create policy http_security_rate_windows_app_be_update
92
+ on public.http_security_rate_windows
93
+ for update
94
+ to app_be
95
+ using (current_user = 'app_be')
96
+ with check (current_user = 'app_be');
97
+
98
+ create policy http_security_rate_windows_app_be_delete
99
+ on public.http_security_rate_windows
100
+ for delete
101
+ to app_be
102
+ using (current_user = 'app_be');
103
+
104
+ revoke all on table public.http_security_rate_windows from public, anon, authenticated;
105
+ grant select, insert, update, delete on table public.http_security_rate_windows to app_be;
@@ -6,7 +6,13 @@ import { CONFIG_ERROR_KINDS } from "@modules/platform/config"
6
6
  import { CqrsModule } from "@modules/platform/cqrs"
7
7
  import { DATABASE_ERROR_KINDS, DatabaseModule } from "@modules/platform/database"
8
8
  import { ERRORS_MESSAGES, ErrorsModule } from "@modules/platform/errors"
9
- import { HTTP_SECURITY_ERROR_KINDS, HTTP_SECURITY_MESSAGES, HttpSecurityModule } from "@modules/platform/http-security"
9
+ import {
10
+ HTTP_SECURITY_ERROR_KINDS,
11
+ HTTP_SECURITY_MESSAGES,
12
+ HttpSecurityModule,
13
+ httpSecurityEntities,
14
+ httpSecurityMigrations,
15
+ } from "@modules/platform/http-security"
10
16
  import { I18nModule } from "@modules/platform/i18n"
11
17
  import { IdsModule } from "@modules/platform/ids"
12
18
  import { HTTP_ERROR_KINDS, HTTP_MESSAGES, HttpModule } from "@modules/platform/http"
@@ -44,6 +50,12 @@ export const platformModules = (options: {{appPascal}}Options): Array<DynamicMod
44
50
  HttpSecurityModule.register({ isGlobal: true, ...options.httpSecurity }),
45
51
  DatabaseModule.register({
46
52
  isGlobal: true,
47
- connections: [{ ...options.database, entities: noteEntities, migrations: noteMigrations }],
53
+ connections: [
54
+ {
55
+ ...options.database,
56
+ entities: [...httpSecurityEntities, ...noteEntities],
57
+ migrations: [...httpSecurityMigrations, ...noteMigrations],
58
+ },
59
+ ],
48
60
  }),
49
61
  ]
@@ -5,14 +5,15 @@ import { DatabaseModule } from "@modules/platform/database"
5
5
  import type { DatabaseConnectionConfig, DatabaseConnectionOptions } from "@modules/platform/database"
6
6
  import { IdsModule } from "@modules/platform/ids"
7
7
  import { HttpModule } from "@modules/platform/http"
8
+ import { httpSecurityEntities, httpSecurityMigrations } from "@modules/platform/http-security"
8
9
  import { LoggingModule } from "@modules/platform/logging"
9
10
  import type { CliAppOptions } from "../cli.options"
10
11
 
11
12
  /** Binds the primary connection to its owners' entities and migrations during composition. */
12
13
  const primaryConnectionOf = (primary: DatabaseConnectionConfig): DatabaseConnectionOptions => ({
13
14
  ...primary,
14
- entities: noteEntities,
15
- migrations: noteMigrations,
15
+ entities: [...httpSecurityEntities, ...noteEntities],
16
+ migrations: [...httpSecurityMigrations, ...noteMigrations],
16
17
  })
17
18
 
18
19
  /** The clock, logger and declared database connections shared by CLI commands. */
@@ -4,6 +4,7 @@ export { HttpSecurityModule } from "./http-security.module"
4
4
  export type { HttpSecurityOptions } from "./http-security.options"
5
5
  export { HTTP_SECURITY_MESSAGES } from "./messages/http-security.messages"
6
6
  export { OriginGuard } from "./origin.guard"
7
+ export { httpSecurityEntities, httpSecurityMigrations } from "./persistence/connection"
7
8
  export { requestOf } from "./execution-request.mapper"
8
9
  export { RateLimitGuard } from "./rate-limit.guard"
9
10
  export { applyHttpSecurity } from "./edge-security.service"
@@ -0,0 +1,8 @@
1
+ import { RateWindowEntity } from "./entities/rate-window.entity"
2
+ import { CreateRateWindows1789900000000 } from "./migrations/1789900000000-create-rate-windows"
3
+
4
+ /** The entities of the http-security capability, for the primary connection its rate limit counts in. */
5
+ export const httpSecurityEntities = [RateWindowEntity]
6
+
7
+ /** The migrations of the http-security capability, in the order they run. */
8
+ export const httpSecurityMigrations = [CreateRateWindows1789900000000]
@@ -0,0 +1,17 @@
1
+ import { Column, Entity, PrimaryColumn } from "typeorm"
2
+
3
+ @Entity("http_security_rate_windows")
4
+ /** One rate limit window of one caller and tier: the hits it counted and when it ends. */
5
+ export class RateWindowEntity {
6
+ /** The tier and caller address the window counts. */
7
+ @PrimaryColumn({ name: "key", type: "varchar", length: 128 })
8
+ key!: string
9
+
10
+ /** The hits counted in the window. */
11
+ @Column({ name: "hits", type: "integer" })
12
+ hits!: number
13
+
14
+ /** When the window ends; a hit after it opens a fresh one. */
15
+ @Column({ name: "reset_at", type: "timestamptz" })
16
+ resetAt!: Date
17
+ }
@@ -0,0 +1,24 @@
1
+ import type { MigrationInterface, QueryRunner } from "typeorm"
2
+
3
+ /** Creates the rate limit windows every api replica counts in. */
4
+ export class CreateRateWindows1789900000000 implements MigrationInterface {
5
+ name = "CreateRateWindows1789900000000"
6
+
7
+ /** Creates the windows table and the index the prune reads. */
8
+ async up(queryRunner: QueryRunner): Promise<void> {
9
+ await queryRunner.query(`CREATE TABLE http_security_rate_windows (
10
+ key varchar(128) PRIMARY KEY,
11
+ hits integer NOT NULL CHECK (hits > 0),
12
+ reset_at timestamptz NOT NULL
13
+ )`)
14
+ await queryRunner.query(
15
+ `CREATE INDEX ix_http_security_rate_windows_reset_at ON http_security_rate_windows (reset_at)`,
16
+ )
17
+ }
18
+
19
+ /** Drops the index and the table. */
20
+ async down(queryRunner: QueryRunner): Promise<void> {
21
+ await queryRunner.query(`DROP INDEX ix_http_security_rate_windows_reset_at`)
22
+ await queryRunner.query(`DROP TABLE http_security_rate_windows`)
23
+ }
24
+ }
@@ -0,0 +1,5 @@
1
+ /** The row HIT_RATE_WINDOW answers. */
2
+ export interface RateWindowRow {
3
+ /** The hits of the caller's current window, this one included. */
4
+ readonly hits: number
5
+ }
@@ -0,0 +1,20 @@
1
+ import { sql } from "@modules/platform/database"
2
+
3
+ /**
4
+ * Counts one hit in the caller's window and answers the hits of that window: a missing or expired window opens afresh at one
5
+ * hit and ends at $3 ($1 tier and address key, $2 instant, $3 end of a fresh window).
6
+ */
7
+ export const HIT_RATE_WINDOW = sql`INSERT INTO http_security_rate_windows (key, hits, reset_at)
8
+ VALUES ($1::varchar, 1, $3::timestamptz)
9
+ ON CONFLICT (key) DO UPDATE SET
10
+ hits = CASE WHEN http_security_rate_windows.reset_at <= $2::timestamptz THEN 1
11
+ ELSE http_security_rate_windows.hits + 1 END,
12
+ reset_at = CASE WHEN http_security_rate_windows.reset_at <= $2::timestamptz THEN EXCLUDED.reset_at
13
+ ELSE http_security_rate_windows.reset_at END
14
+ RETURNING hits`
15
+
16
+ /** Deletes a bounded batch of the windows that ended by $1; a second replica skips the rows this one holds. */
17
+ export const PRUNE_RATE_WINDOWS = sql`DELETE FROM http_security_rate_windows WHERE key IN (
18
+ SELECT key FROM http_security_rate_windows WHERE reset_at <= $1::timestamptz
19
+ LIMIT 100 FOR UPDATE SKIP LOCKED
20
+ )`