diff --git a/.cursor/rules/CODE_STYLE.mdc b/.cursor/rules/CODE_STYLE.mdc index 0c60597..9554584 100644 --- a/.cursor/rules/CODE_STYLE.mdc +++ b/.cursor/rules/CODE_STYLE.mdc @@ -180,7 +180,7 @@ function getData() {} ```typescript // ✅ Good - Shades component with event handlers const MyComponent = Shade({ - shadowDomName: 'my-component', + customElementName: 'my-component', render: ({ props, injector }) => { const handleButtonClick = () => { // Internal logic @@ -267,7 +267,7 @@ type UserProfileProps = { }; export const UserProfile = Shade({ - shadowDomName: 'user-profile', + customElementName: 'user-profile', render: ({ props }) => { // Component implementation }, @@ -501,7 +501,7 @@ const formatDate = (date: Date): string => { // 5. Main component export const UserProfile = Shade({ - shadowDomName: 'user-profile', + customElementName: 'user-profile', render: ({ props, injector, useObservable, useDisposable }) => { // Services const userService = injector.getInstance(UserService); @@ -646,7 +646,7 @@ export const formatCurrency = (value: number, currency: string): string => { * UserProfile component displays user information and allows editing */ export const UserProfile = Shade({ - shadowDomName: 'user-profile', + customElementName: 'user-profile', render: ({ props }) => { // Component implementation }, diff --git a/.cursor/rules/REST_SERVICE.mdc b/.cursor/rules/REST_SERVICE.mdc index f15c84c..eafe6c1 100644 --- a/.cursor/rules/REST_SERVICE.mdc +++ b/.cursor/rules/REST_SERVICE.mdc @@ -21,11 +21,8 @@ import StackCraftApiSchemas from 'common/schemas/stack-craft-api.json' with { ty import { useHttpAuthentication, useRestService, useStaticFiles } from '@furystack/rest-service' import { injector } from './config.js' -// Set up authentication -useHttpAuthentication(injector, { - getUserStore: (sm) => sm.getStoreFor(User, 'username'), - getSessionStore: (sm) => sm.getStoreFor(DefaultSession, 'sessionId'), -}) +// Set up authentication (requires DataSets for User and DefaultSession to be pre-registered) +useHttpAuthentication(injector) // Set up REST API useRestService({ @@ -185,8 +182,8 @@ import { FileSystemStore } from '@furystack/filesystem-store' import { Injector } from '@furystack/inject' import { useLogging, VerboseConsoleLogger } from '@furystack/logging' import { getRepository } from '@furystack/repository' -import { usePasswordPolicy } from '@furystack/security' import { DefaultSession } from '@furystack/rest-service' +import { PasswordResetToken, usePasswordPolicy } from '@furystack/security' import { User } from 'common' export const injector = new Injector() @@ -204,13 +201,12 @@ addStore( }), ) -addStore( - injector, - new InMemoryStore({ - model: DefaultSession, - primaryKey: 'sessionId', - }), -) +addStore(injector, new InMemoryStore({ model: DefaultSession, primaryKey: 'sessionId' })) + .addStore(new InMemoryStore({ model: PasswordResetToken, primaryKey: 'token' })) + +// Create DataSets (required before useHttpAuthentication and usePasswordPolicy) +getRepository(injector).createDataSet(DefaultSession, 'sessionId') +getRepository(injector).createDataSet(PasswordResetToken, 'token') // Set up password policy usePasswordPolicy(injector) @@ -237,6 +233,74 @@ export const authorizedDataSet: Partial> = { } ``` +## Data Access + +### NEVER Use `getStoreManager()` or `StoreManager` Directly + +All data access **must** go through the Repository layer (`getRepository()`) using DataSets. Direct `StoreManager` / `getStoreManager()` usage bypasses authorization and is **forbidden**. + +```typescript +// ❌ FORBIDDEN - bypasses authorization +import { getStoreManager } from '@furystack/core' +const sm = getStoreManager(injector) +const users = await sm.getStoreFor(User, 'username').find({}) + +// ❌ FORBIDDEN - direct StoreManager injection +@Injected(StoreManager) +declare private storeManager: StoreManager + +// ✅ Good - use Repository DataSets +import { getRepository } from '@furystack/repository' +const repository = getRepository(injector) +const users = await repository.getDataSetFor(User, 'username').find(injector, {}) +``` + +### REST Action Handlers + +REST action handlers receive a scoped `injector` with an `IdentityContext` already set up per-request. Pass it directly to DataSet methods: + +```typescript +const MyAction: RequestAction = async ({ injector }) => { + const repository = getRepository(injector) + const items = await repository.getDataSetFor(MyModel, 'id').find(injector, {}) + return JsonResult({ items }) +} +``` + +### Elevated Context for Background Operations + +Background services, middleware, and startup code have no HTTP request context. Use `useSystemIdentityContext()` from `@furystack/core` to create a child injector with system-level privileges: + +```typescript +import { useSystemIdentityContext } from '@furystack/core' +import { getRepository } from '@furystack/repository' +import { usingAsync } from '@furystack/utils' + +// One-off operation (automatic cleanup with usingAsync) +await usingAsync(useSystemIdentityContext({ injector }), async (elevated) => { + const repository = getRepository(elevated) + const items = await repository.getDataSetFor(MyModel, 'id').find(elevated, {}) + await repository.getDataSetFor(MyModel, 'id').update(elevated, id, changes) +}) + +// Singleton services (cache and dispose with service) +@Injectable({ lifetime: 'singleton' }) +export class MyService { + private elevatedInjector?: Injector + + private getElevatedInjector(): Injector { + if (!this.elevatedInjector) { + this.elevatedInjector = useSystemIdentityContext({ injector: getInjectorReference(this) }) + } + return this.elevatedInjector + } + + public async [Symbol.asyncDispose]() { + await this.elevatedInjector?.[Symbol.asyncDispose]() + } +} +``` + ## Store Types ### FileSystemStore @@ -316,26 +380,25 @@ void attachShutdownHandler(injector) ### Seed Script -Create a seed script for initial data: +Create a seed script for initial data using elevated context: ```typescript // service/src/seed.ts -import { StoreManager } from '@furystack/core' +import { useSystemIdentityContext } from '@furystack/core' +import { getRepository } from '@furystack/repository' +import { usingAsync } from '@furystack/utils' import { PasswordAuthenticator, PasswordCredential } from '@furystack/security' import { User } from 'common' import { injector } from './config.js' export const seed = async (i: Injector): Promise => { - const sm = i.getInstance(StoreManager) - const userStore = sm.getStoreFor(User, 'username') - const pwcStore = sm.getStoreFor(PasswordCredential, 'userName') - - // Create default user credentials - const cred = await i.getInstance(PasswordAuthenticator).hasher.createCredential('testuser', 'password') + await usingAsync(useSystemIdentityContext({ injector: i }), async (elevated) => { + const repository = getRepository(elevated) + const cred = await i.getInstance(PasswordAuthenticator).hasher.createCredential('testuser', 'password') - // Save to stores - await pwcStore.add(cred) - await userStore.add({ username: 'testuser', roles: [] }) + await repository.getDataSetFor(PasswordCredential, 'userName').add(elevated, cred) + await repository.getDataSetFor(User, 'username').add(elevated, { username: 'testuser', roles: [] }) + }) } await seed(injector) @@ -375,16 +438,21 @@ useRestService({ 3. **Validate requests** - Use `Validate` wrapper with JSON schemas 4. **Configure stores** - `FileSystemStore` for persistence, `InMemoryStore` for sessions 5. **Handle authorization** - Define authorization functions for data sets -6. **Graceful shutdown** - Implement proper cleanup with `Symbol.asyncDispose` -7. **CORS setup** - Configure for frontend origins +6. **NEVER use `getStoreManager()` or `StoreManager` directly** - Always use `getRepository().getDataSetFor()` for data access +7. **Use `useSystemIdentityContext()` from `@furystack/core`** for background services, middleware, and startup operations that lack an HTTP request context +8. **Graceful shutdown** - Implement proper cleanup with `Symbol.asyncDispose` +9. **CORS setup** - Configure for frontend origins **Service Checklist:** - [ ] API types defined in `common` package - [ ] JSON schemas generated for validation - [ ] Stores configured for all models -- [ ] Authentication set up with `useHttpAuthentication` +- [ ] DataSets created for all models via `getRepository().createDataSet()` (including `DefaultSession` and `PasswordResetToken`) +- [ ] Authentication set up with `useHttpAuthentication` (no options needed — uses DataSets by default) - [ ] Authorization functions defined +- [ ] No `getStoreManager()` or `StoreManager` usage — all data access via Repository +- [ ] Background services use `useSystemIdentityContext()` for data access - [ ] CORS configured for frontend - [ ] Graceful shutdown handler attached - [ ] Error handling for startup failures diff --git a/.cursor/rules/SHADES_COMPONENTS.mdc b/.cursor/rules/SHADES_COMPONENTS.mdc index ea9e0f1..81a9d34 100644 --- a/.cursor/rules/SHADES_COMPONENTS.mdc +++ b/.cursor/rules/SHADES_COMPONENTS.mdc @@ -24,7 +24,7 @@ type MyComponentProps = { }; export const MyComponent = Shade({ - shadowDomName: 'my-component', + customElementName: 'my-component', render: ({ props, injector }) => { return (
@@ -42,15 +42,15 @@ Always provide a unique `shadowDomName` in kebab-case: ```typescript // ✅ Good - unique, descriptive kebab-case names -shadowDomName: 'shade-app-layout' -shadowDomName: 'shade-login' -shadowDomName: 'theme-switch' -shadowDomName: 'github-logo' +customElementName: 'shade-app-layout' +customElementName: 'shade-login' +customElementName: 'theme-switch' +customElementName: 'github-logo' // ❌ Avoid - generic or poorly named -shadowDomName: 'my-component' -shadowDomName: 'div' -shadowDomName: 'Component1' +customElementName: 'my-component' +customElementName: 'div' +customElementName: 'Component1' ``` ## Render Function Parameters @@ -79,11 +79,11 @@ Get service instances using the injector: ```typescript export const MyComponent = Shade({ - shadowDomName: 'my-component', + customElementName: 'my-component', render: ({ injector }) => { const themeProvider = injector.getInstance(ThemeProviderService); const sessionService = injector.getInstance(SessionService); - + return (
{/* Component content */} @@ -101,10 +101,10 @@ Use `useState` for component-local state: ```typescript export const Counter = Shade({ - shadowDomName: 'app-counter', + customElementName: 'app-counter', render: ({ useState }) => { const [count, setCount] = useState('count', 0); - + return (
Count: {count} @@ -121,10 +121,10 @@ Subscribe to `ObservableValue` from services: ```typescript export const UserStatus = Shade({ - shadowDomName: 'user-status', + customElementName: 'user-status', render: ({ injector, useObservable }) => { const sessionService = injector.getInstance(SessionService); - + // Subscribe to observable values const [isOperationInProgress] = useObservable( 'isOperationInProgress', @@ -132,11 +132,11 @@ export const UserStatus = Shade({ ); const [currentUser] = useObservable('currentUser', sessionService.currentUser); const [state] = useObservable('state', sessionService.state); - + if (isOperationInProgress) { return
Loading...
; } - + return (
{currentUser ? `Welcome, ${currentUser.username}` : 'Not logged in'} @@ -154,18 +154,18 @@ Properly manage subscriptions and resources: ```typescript export const ThemeSwitch = Shade({ - shadowDomName: 'theme-switch', + customElementName: 'theme-switch', render: ({ injector, useState, useDisposable }) => { const themeProvider = injector.getInstance(ThemeProviderService); const [theme, setTheme] = useState<'light' | 'dark'>('theme', 'dark'); - + // Subscribe to theme changes with automatic cleanup useDisposable('traceThemeChange', () => themeProvider.subscribe('themeChanged', (newTheme) => { setTheme(newTheme.name === 'dark' ? 'dark' : 'light'); }), ); - + return ; }, }); @@ -188,10 +188,10 @@ import { } from '@furystack/shades-common-components'; export const LoginForm = Shade({ - shadowDomName: 'login-form', + customElementName: 'login-form', render: ({ injector }) => { const sessionService = injector.getInstance(SessionService); - + return ( @@ -210,6 +210,100 @@ export const LoginForm = Shade({ }); ``` +## Form Handling + +### Always use `Form` from `@furystack/shades-common-components` + +Never use raw `
` HTML elements. The `Form` component provides type-safe form data collection, two-tier validation (input + form level), and integration with all form field components (`Input`, `Select`, `Checkbox`, etc.). + +### Pattern: Typed Form Payload + +Every form needs: + +1. A **payload type** describing the form data shape +2. A **type-guard `validate` function** that narrows `unknown` to the payload type +3. A `Form` component with `validate` and `onSubmit` props + +```typescript +import { Form, Input, Button } from '@furystack/shades-common-components'; + +type CreateStackPayload = { + name: string; + displayName: string; + description: string; + mainDirectory: string; +}; + +const isCreateStackPayload = (data: unknown): data is CreateStackPayload => { + const d = data as CreateStackPayload; + return d.name?.length > 0 && d.displayName?.length > 0 && d.mainDirectory?.length > 0; +}; + +// In render: + + validate={isCreateStackPayload} + onSubmit={(data) => { + // `data` is fully typed as CreateStackPayload + void handleSubmit(data); + }} + style={{ display: 'flex', flexDirection: 'column', gap: '16px' }} +> + + + + + + +``` + +### Use `Checkbox` component instead of raw `` + +```typescript +import { Checkbox } from '@furystack/shades-common-components'; + +// ✅ Good - uses Checkbox component, integrates with Form + + +// ❌ Avoid - raw checkbox, no FormService integration + +``` + +### Forbidden Patterns + +Never use these patterns for form data handling: + +```typescript +// ❌ FORBIDDEN - raw
tag + { ... }}> + +// ❌ FORBIDDEN - FormData extraction from DOM +const formData = new FormData(ev.target as HTMLFormElement); +const data = Object.fromEntries(formData.entries()) as Record; + +// ❌ FORBIDDEN - useRef to imperatively read form data +const formRef = useRef('myForm'); +const data = new FormData(formRef.current); + +// ❌ FORBIDDEN - direct DOM value access for form fields +oninput={(ev) => setValue((ev.target as HTMLInputElement).value)} +``` + +### Form Field Components + +Use these components from `@furystack/shades-common-components` inside `Form`: + +- `Input` - Text, number, email, password fields +- `Select` - Single/multi select dropdowns +- `Checkbox` - Boolean checkboxes +- `TextArea` - Multi-line text input +- `Switch` - Toggle switches +- `Radio` / `RadioGroup` - Radio button groups + +All form field components automatically register with the parent `Form` via the `FormService` and participate in validation. + ## Theming ### Access Theme Properties @@ -218,10 +312,10 @@ Use `ThemeProviderService` for consistent theming: ```typescript export const ThemedComponent = Shade({ - shadowDomName: 'themed-component', + customElementName: 'themed-component', render: ({ injector }) => { const theme = injector.getInstance(ThemeProviderService).theme; - + return (
{ - themeProvider.setAssignedTheme( - currentTheme === 'dark' ? defaultLightTheme : defaultDarkTheme - ); -}; + themeProvider.setAssignedTheme(currentTheme === 'dark' ? defaultLightTheme : defaultDarkTheme) +} ``` ## Page Components @@ -262,7 +354,7 @@ Place page components in `frontend/src/pages/`: import { createComponent, Shade } from '@furystack/shades'; export const Dashboard = Shade({ - shadowDomName: 'page-dashboard', + customElementName: 'page-dashboard', render: ({ injector }) => { return (
@@ -280,9 +372,9 @@ Export pages from an index file: ```typescript // frontend/src/pages/index.ts -export * from './dashboard.js'; -export * from './login.js'; -export * from './hello-world.js'; +export * from './dashboard.js' +export * from './login.js' +export * from './hello-world.js' ``` ## Application Entry Point @@ -332,6 +424,7 @@ initializeShadeRoot({ 6. **useDisposable for cleanup** - Manage subscriptions properly 7. **Use common components** - Leverage `@furystack/shades-common-components` 8. **Consistent theming** - Use `ThemeProviderService` for styles +9. **Use `Form` for all forms** - Never use raw `` tags, `FormData`, or `useRef` for form handling **Component Checklist:** @@ -341,3 +434,4 @@ initializeShadeRoot({ - [ ] Observable subscriptions use `useObservable` - [ ] Manual subscriptions cleaned up with `useDisposable` - [ ] Theme values from `ThemeProviderService` +- [ ] Forms use `Form` with typed payload and validate function diff --git a/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc b/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc index d64bd6f..33068d4 100644 --- a/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc +++ b/.cursor/rules/TYPESCRIPT_GUIDELINES.mdc @@ -28,7 +28,7 @@ type ButtonProps = { }; export const Button = Shade({ - shadowDomName: 'app-button', + customElementName: 'app-button', render: ({ props }) => { const variant = props.variant ?? 'contained'; const color = props.color ?? 'primary'; @@ -51,7 +51,7 @@ export const Button = Shade({ // ✅ Good - explicit return type for complex conditional logic export const StatusBadge = Shade<{ status: string }>({ - shadowDomName: 'status-badge', + customElementName: 'status-badge', render: ({ props }): JSX.Element | null => { switch (props.status) { case 'active': @@ -88,7 +88,7 @@ type UserProfileProps = { } export const UserProfile = Shade({ - shadowDomName: 'user-profile', + customElementName: 'user-profile', render: ({ props }) => { // Component implementation }, @@ -132,7 +132,7 @@ type ListProps = { }; export const List = (props: ListProps) => Shade({ - shadowDomName: 'generic-list', + customElementName: 'generic-list', render: () => { return (