# Agents Guide — RouteKit Classic ASP / Keycloak Test ## What this project is A Classic ASP MVC web application running on IIS (Windows). It uses the **RouteKit** framework (front-controller pattern) with Keycloak OpenID Connect for authentication. The database is Microsoft Access (`.accdb`). There is no Node, npm, or build step — everything is server-side VBScript served by IIS. --- ## Project layout ``` public/ IIS site root — point IIS here Default.asp Front controller and route table web.config App settings, URL rewrite rules core/ Framework internals — do not modify autoload_core.asp mvc.asp router.wsc lib.*.asp Core libraries (see below) helpers.asp Global utility functions (always available) app/ controllers/ Controller classes (one per feature area) views/ View partials (folders match controller name) shared/ Header, footer, layout partials models/ POBO (plain-old business objects) repositories/ Data access classes db/ migrations/ Sequential migration scripts webdata.accdb Access database tests/ Dev-only aspunit test harness (separate IIS app) scripts/ VBScript code generators ``` --- ## Core libraries (core/lib.*.asp) | File | Purpose | |---|---| | `lib.Keycloak.asp` | OpenID Connect / Keycloak auth helper | | `lib.Routes.asp` | URL generation helpers (`Routes()` singleton) | | `lib.ControllerRegistry.asp` | Controller whitelist (security) | | `lib.DAL.asp` | Database access layer (`DAL` singleton) | | `lib.Data.asp` | Data helpers | | `lib.Collections.asp` | `LinkedList_Class` and other collection types | | `lib.Enumerable.asp` | Collection iteration helpers | | `lib.Validations.asp` | Input validation | | `lib.Flash.asp` | Flash message helpers | | `lib.FormCache.asp` | Form value re-population | | `lib.HTML.asp` | HTML rendering helpers | | `lib.HTML.Security.asp` | XSS escaping — `H()` function | | `lib.Strings.asp` | String utilities | | `lib.Automapper.asp` | `Automapper.AutoMap(rs, "POBO_TableName")` | | `lib.ErrorHandler.asp` | Error handling | | `lib.Migrations.asp` | Migration runner | | `lib.json.asp` | JSON parsing/serialization | | `lib.Upload.asp` | File upload helper | | `lib.CDOEmail.asp` | Email via CDO | | `lib.ad.auth.asp` | Active Directory auth | | `lib.crypto.helper.asp` | Crypto utilities | --- ## Global helper functions (core/helpers.asp) These are always available — never re-implement them. | Function | Signature | Purpose | |---|---|---| | `H` | `H(s)` | XSS-safe HTML encode — use on all user data rendered to HTML | | `GenerateSlug` | `GenerateSlug(title)` | Converts a title to a safe URL slug (lowercase, hyphens, alphanumeric only) | | `GetRawJsonFromRequest` | `GetRawJsonFromRequest()` | Reads raw JSON body from an AJAX POST request | | `GetAppSetting` | `GetAppSetting(key)` | Reads a value from `web.config` appSettings (cached in `Application`) | | `IIf` | `IIf(condition, trueVal, falseVal)` | Inline conditional | | `TrimQueryParams` | `TrimQueryParams(path)` | Strips query string from a URL path | | `Destroy` | `Destroy(obj)` | Safely closes and sets an object to Nothing | | `QuoteValue` | `QuoteValue(val)` | Quotes a value for SQL (prefer parameterized queries via DAL instead) | | `FormatDateForSql` | `FormatDateForSql(date)` | Formats a VBScript date as a SQL Server datetime string | | `Active` | `Active(controllerName)` | Returns `"active"` if the current request maps to that controller | | `SurroundString` | `SurroundString(val)` | Wraps strings in double-quotes (used internally by dispatcher) | | `GetDynamicProperty` | `GetDynamicProperty(obj, propName)` | Reads a property by name from any VBScript object | | `RenderObjectsAsTable` | `RenderObjectsAsTable(arr, useTabulator)` | Generates an HTML table from an array of POBOs | | `RenderFormFromObject` | `RenderFormFromObject(obj)` | Generates an HTML form from a POBO | --- ## Router — URL parameters Routes are registered in [public/Default.asp](public/Default.asp) and support `:param` segments. **Registering routes:** ```vbscript ' Static route router.AddRoute "GET", "/boards", "BoardsController", "Index" router.AddRoute "POST", "/boards", "BoardsController", "Store" ' Route with one URL parameter router.AddRoute "GET", "/board/:slug", "BoardsController", "Show" router.AddRoute "POST", "/board/:slug", "BoardsController", "Update" ' Route with one URL parameter and a sub-action router.AddRoute "POST", "/cards/:id/move", "CardsController", "Move" router.AddRoute "POST", "/cards/:id/delete", "CardsController", "Destroy" ``` **Controller action receives parameters as arguments in order:** ```vbscript ' Matches /board/:slug Public Sub Show(slug) ' slug is the extracted URL segment End Sub ' Matches /cards/:id/move Public Sub Move(id) ' id is the extracted URL segment End Sub ``` The router does an **exact match first**, then falls back to `:param` pattern matching. If nothing matches it returns `ErrorController#NotFound`. **URL generation from controllers/views:** ```vbscript ' /boards Routes.UrlTo "Boards", "Index", Empty ' /board/my-board (route param appended as positional segment) Routes.UrlToWithParams "Boards", "Show", Array("my-board"), Empty ' With query string Routes.UrlTo "Boards", "Index", Array("page", 2) ``` All requests are rewritten through `Default.asp` by the IIS URL Rewrite rule. Static assets (`css/`, `js/`, `images/`, `favicon.ico`) bypass the rewrite. --- ## POBO pattern (app/models/) Plain-Old Business Object — one class per table. Generated by `scripts/GenerateRepo.vbs`. ```vbscript <% Class POBO_boards Public Properties ' array of all column names Private p_id Private p_name Private p_slug Private p_created_at Private p_created_by Private p_updated_at Private p_updated_by Private Sub Class_Initialize() p_id = 0 p_name = "" p_slug = "" p_created_at = #1/1/1970# p_created_by = "" p_updated_at = #1/1/1970# p_updated_by = "" Properties = Array("id","name","slug","created_at","created_by","updated_at","updated_by") End Sub Public Property Get PrimaryKey() : PrimaryKey = "id" : End Property Public Property Get TableName() : TableName = "boards" : End Property Public Property Get id() : id = p_id : End Property Public Property Let id(v) : p_id = CDbl(v) : End Property Public Property Get name() : name = p_name : End Property Public Property Let name(v) : p_name = CStr(v) : End Property ' ... remaining Get/Let pairs follow same pattern End Class %> ``` Key rules: - Private backing fields use the `p` prefix: `p_id`, `p_name`, etc. - `Properties` array lists all column names — required by `Automapper` and `RenderObjectsAsTable`. - `PrimaryKey` and `TableName` must match the actual table. --- ## Repository pattern (app/repositories/) Generated by `scripts/GenerateRepo.vbs`. Uses the `DAL` singleton and `Automapper`. ```vbscript <% Class boards_Repository_Class ' Single record by PK Public Function FindByID(id) Dim sql : sql = "SELECT [id],[name],[slug],[created_at],[created_by],[updated_at],[updated_by] FROM [boards] WHERE [id] = ?" Dim rs : Set rs = DAL.Query(sql, Array(id)) If rs.EOF Then Err.Raise 1, "boards_Repository_Class", "boards record not found with id = " & id Else Set FindByID = Automapper.AutoMap(rs, "POBO_boards") End If Destroy rs End Function ' All records, optional order Public Function GetAll(orderBy) Set GetAll = Find(Empty, orderBy) End Function ' Filtered list — where_kvarray is a flat key/value array: Array("col", val, "col2", val2) Public Function Find(where_kvarray, order_string_or_array) Dim sql : sql = "SELECT ... FROM [boards]" Dim where_keys, where_values, i If Not IsEmpty(where_kvarray) Then KVUnzip where_kvarray, where_keys, where_values ' appends WHERE clause for each key End If sql = sql & BuildOrderBy(order_string_or_array, "[id]") Dim rs : Set rs = DAL.Query(sql, where_values) Dim list : Set list = New LinkedList_Class Do Until rs.EOF list.Push Automapper.AutoMap(rs, "POBO_boards") rs.MoveNext Loop Set Find = list Destroy rs End Function ' Insert — sets model.id to the new identity value Public Sub AddNew(ByRef model) Dim sql : sql = "INSERT INTO [boards] ([name],[slug],...) VALUES (?,?,...)" DAL.Execute sql, Array(model.name, model.slug, ...) Dim rsId : Set rsId = DAL.Query("SELECT @@IDENTITY AS NewID", Empty) If Not rsId.EOF Then If Not IsNull(rsId(0)) Then model.id = rsId(0) End If Destroy rsId End Sub ' Update — all columns except PK, PK appended last for WHERE Public Sub Update(model) Dim sql : sql = "UPDATE [boards] SET [name]=?,[slug]=?,... WHERE [id]=?" DAL.Execute sql, Array(model.name, model.slug, ..., model.id) End Sub ' Delete by PK Public Sub Delete(id) DAL.Execute "DELETE FROM [boards] WHERE [id]=?", Array(id) End Sub End Class Dim boards_Repository__Singleton Function boards_Repository() If IsEmpty(boards_Repository__Singleton) Then Set boards_Repository__Singleton = New boards_Repository_Class End If Set boards_Repository = boards_Repository__Singleton End Function %> ``` --- ## Controller pattern (app/controllers/) Generated by `scripts/generateController.vbs`. Singleton per request. ```vbscript <% Class BoardsController_Class Private m_useLayout Private m_title Private Sub Class_Initialize() m_useLayout = True m_title = "Boards" End Sub Public Property Get useLayout() : useLayout = m_useLayout : End Property Public Property Let useLayout(v) : m_useLayout = v : End Property Public Property Get Title() : Title = m_title : End Property Public Property Let Title(v) : m_title = v : End Property ' GET /boards Public Sub Index() If Not KeycloakRequireLogin("") Then Exit Sub ' ... load data, render view End Sub ' GET /board/:slug — slug comes from the URL parameter Public Sub Show(slug) If Not KeycloakRequireLogin("") Then Exit Sub ' ... load board by slug, render view End Sub ' POST /boards — reads from Request.Form Public Sub Store() If Not KeycloakRequireLogin("") Then Exit Sub ' ... validate, save, redirect End Sub ' JSON endpoint — set useLayout = False, write JSON response Public Sub Move(id) m_useLayout = False Response.ContentType = "application/json" If Not KeycloakIsLoggedIn() Then Response.Write "{""ok"":false,""error"":""Unauthorized""}" Exit Sub End If ' ... process, write JSON End Sub End Class Dim BoardsController_Class__Singleton Function BoardsController() If IsEmpty(BoardsController_Class__Singleton) Then Set BoardsController_Class__Singleton = New BoardsController_Class End If Set BoardsController = BoardsController_Class__Singleton End Function %> ``` **JSON / AJAX actions** must set `m_useLayout = False` and `Response.ContentType = "application/json"`. **Reading a JSON request body** (sent by `fetch` / XHR): ```vbscript Dim rawJson : rawJson = GetRawJsonFromRequest() ' then parse with lib.json.asp ``` --- ## Audit columns **Every table must include these four audit columns:** | Column | Type | Populated by | |---|---|---| | `created_at` | DateTime | Set once on insert: `Now()` | | `created_by` | Text(255) | Set once on insert: `preferred_username` from Keycloak | | `updated_at` | DateTime | Updated on every save: `Now()` | | `updated_by` | Text(255) | Updated on every save: `preferred_username` from Keycloak | **Getting the current username in a controller action:** ```vbscript Dim currentUser : Set currentUser = KeycloakCurrentUser() Dim currentUsername : currentUsername = "" If Not currentUser Is Nothing Then currentUsername = currentUser.Item("preferred_username") End If ``` **Setting audit fields before insert:** ```vbscript model.created_at = Now() model.created_by = currentUsername model.updated_at = Now() model.updated_by = currentUsername repo.AddNew model ``` **Setting audit fields before update:** ```vbscript model.updated_at = Now() model.updated_by = currentUsername repo.Update model ``` `created_at` and `created_by` are **never changed** after the initial insert. --- ## Slug generation `GenerateSlug(title)` is already in `core/helpers.asp`. Always use it — never write your own slug logic. ```vbscript Dim slug : slug = GenerateSlug(Request.Form("name")) ' "My Board & Things!" → "my-board-and-things" ``` After generating, check uniqueness against the database before saving. If the slug already exists, append a suffix (e.g., `-2`, `-3`). --- ## Wiring up a new controller — checklist 1. Generate: `cscript //nologo scripts\generateController.vbs MyController "Index;Show(slug);Store"` 2. Move generated file to `app/controllers/` 3. Register in [core/lib.ControllerRegistry.asp](core/lib.ControllerRegistry.asp): `RegisterController "mycontroller"` 4. Include in [app/controllers/autoload_controllers.asp](app/controllers/autoload_controllers.asp): `` 5. Add routes in [public/Default.asp](public/Default.asp) 6. Create views in `app/views/MyController/` --- ## Authentication (Keycloak) The helper in `core/lib.Keycloak.asp` implements the OpenID Connect authorization-code flow. **Key functions:** ```vbscript KeycloakRequireLogin("") ' Gate full-page actions — redirects if not logged in; returns False to signal early exit KeycloakIsLoggedIn() ' Use this for JSON/AJAX actions instead of RequireLogin KeycloakCurrentUser() ' Returns userinfo Dictionary — keys include preferred_username, email, name, sub KeycloakLogin() ' Redirect to Keycloak KeycloakHandleCallback() ' Exchange code, store tokens — returns True on success KeycloakAccessToken() ' Raw access token string KeycloakRefreshToken() KeycloakIdToken() KeycloakTokenClaims(token) ' Decode JWT payload into dictionary KeycloakConsumePostLoginRedirectPath("/") ' Get and clear stored return path KeycloakHasRealmRole("admin") ' Role check against ID token KeycloakHasClientRole(clientId, role) KeycloakLogout("") ' Clear session and redirect to Keycloak logout ``` Session keys use the `Keycloak_` prefix. Tokens are stored in `Session`, not cookies. --- ## Configuration (public/web.config appSettings) | Key | Description | |---|---| | `ConnectionString` | ACE OLEDB path to `webdata.accdb` | | `Environment` | `Development`, `Staging`, or `Production` | | `KeycloakBaseUrl` | Keycloak server base URL (no `/realms/...`) | | `KeycloakRealm` | Keycloak realm name | | `KeycloakClientId` | Client ID | | `KeycloakClientSecret` | Client secret — never commit real values | | `KeycloakRedirectUri` | Absolute callback URL | | `KeycloakLogoutRedirectUri` | Post-logout redirect URL | | `KeycloakScope` | OIDC scopes (default: `openid profile email`) | | `KeycloakEnableLogging` | `true`/`false` | | `KeycloakLogPath` | Path to Keycloak log file | | `EnableErrorLogging` | `true`/`false` | | `ErrorLogPath` | Path to error log file | --- ## Testing Tests live in `tests/` and run as a **separate IIS application** (never through the production `public/` root). - Framework: `tests/aspunit/` (vendored) - Runner: browse to `run-all.asp` in the test IIS app - Manifest: `tests/test-manifest.asp` — register new test pages here manually - Bootstrap: `tests/bootstrap.asp` — shared setup for all test pages | Folder | Use for | |---|---| | `tests/unit/` | Deterministic helper and registry tests | | `tests/component/` | Controlled controller/object tests | | `tests/integration/` | Router/dispatch smoke tests, config behavior, rendered-page capture | After changing `tests/web.config`: `cscript //nologo tests\sync-webconfigs.vbs` --- ## Requirements - Windows Server / Windows with IIS - Classic ASP enabled - IIS URL Rewrite module - Microsoft Access Database Engine (ACE OLEDB 12.0) - Keycloak server (for auth flows) --- ## LinkedList_Class — correct traversal `LinkedList_Class` (from `core/lib.Collections.asp`) does **not** have public `.First` or `.Next` properties — `m_first` and `m_last` are private fields. Calling `.First` on a list throws Error 438. **Always use the `Iterator()` pattern:** ```vbscript Dim iter : Set iter = myList.Iterator() Do While iter.HasNext() Set item = iter.GetNext() ' returns the value directly — NOT a node wrapper ' use item Loop ``` `GetNext()` returns the value object directly, so there is no `.m_value` or `.m_next` to dereference. **Other valid approaches:** ```vbscript ' Convert to array for indexed access Dim arr : arr = myList.TO_Array() For i = 0 To UBound(arr) Set item = arr(i) Next ' Get single values at the ends myList.Front() ' returns first value myList.Back() ' returns last value myList.Count ' number of elements myList.IsEmpty() ' Boolean ``` Never use `.First`, `.Last`, `.m_first`, `.m_next`, or `.m_value` on a list — those are internal node fields, not a public API. --- ## AJAX form data — always use URLSearchParams, never FormData Classic ASP's `Request.Form` collection **only parses `application/x-www-form-urlencoded`** POST bodies. Using `new FormData()` in JavaScript sends `multipart/form-data`, which Classic ASP silently ignores — every field comes back as an empty string. **Always use `URLSearchParams` for fetch POST requests that the server reads via `Request.Form`:** ```javascript function post(url, data, cb) { var params = new URLSearchParams(); Object.keys(data).forEach(function (k) { params.append(k, data[k]); }); fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params.toString() }) .then(function (r) { return r.json(); }) .then(cb) .catch(function (e) { console.error(url, e); }); } ``` **`Request.BinaryRead` vs `Request.Form` mutual exclusion:** Once you call `Request.BinaryRead()` (which `GetRawJsonFromRequest()` does internally), `Request.Form` becomes unavailable for that same request, and vice versa. Never mix the two in the same action. Use `GetRawJsonFromRequest()` only in actions that receive a raw JSON body (e.g. `Reorder`), and use `Request.Form` only in actions that receive URL-encoded form data (e.g. `Store`, `Update`, `Move`). --- ## Critical Classic ASP scoping rule for views Controller actions include view files via SSI (``). The included file's content compiles **inside** the controller action's Sub/Function scope. This means: - **Never define `Function` or `Sub` inside a view file** — VBScript forbids nested procedure definitions. You will get `Syntax error 800a03ea`. - All `Dim`, `ReDim`, loops, and conditionals in a view are fine — only `Function`/`Sub` declarations are forbidden. - If a view needs a helper function, define it as a `Private Function` in the controller class, build the result in the action, and pass it as a variable the view can reference. - Keep views to pure HTML rendering: `<%= variable %>`, `H(value)`, simple `<% If / For / Do %>` blocks, and includes of other partials. --- ## Things to avoid - Do not modify files under `core/` — these are framework internals. - Do not add controllers without registering them in `ControllerRegistry` — the MVC dispatcher will reject unregistered names. - Do not commit real `KeycloakClientSecret` values — inject per environment. - Do not add test routes or test pages under `public/`. - Do not use the production `public/` IIS app to run tests. - Always use `H()` when rendering user-supplied data to prevent XSS. - Never write your own slug generator — use `GenerateSlug()` from `helpers.asp`. - Never use `Private Const` or `Public Const` inside a VBScript class — it causes a syntax error. Use a `Private Function` that returns the value instead. - In Access/Jet SQL DDL, common reserved words include `COLUMNS`, `NAME`, `POSITION`, `VALUE`, `DATE`, `KEY`, `LEVEL`, `BY`. **Always use `migration.ExecuteSQL` and bracket every identifier** — both table names and column names — e.g. `CREATE TABLE [board_columns] ([id] AUTOINCREMENT PRIMARY KEY, [name] VARCHAR(255), [position] INTEGER, ...)`. Never use `migration.CreateTable` or `migration.CreateIndex`; use `migration.ExecuteSQL` for all DDL. - Never read raw JSON bodies manually — use `GetRawJsonFromRequest()` from `helpers.asp`. - Never use `new FormData()` in JavaScript for requests that Classic ASP reads via `Request.Form` — it sends `multipart/form-data` which Classic ASP silently ignores. Use `URLSearchParams` instead. - Never mix `Request.BinaryRead` / `GetRawJsonFromRequest` with `Request.Form` in the same action — they are mutually exclusive in Classic ASP. - Never call `.First`, `.Last`, `.m_first`, `.m_next`, or `.m_value` on a `LinkedList_Class` instance — those are private internals. Use `.Iterator()` / `HasNext()` / `GetNext()` or `.TO_Array()`. - Always set all four audit columns (`created_at`, `created_by`, `updated_at`, `updated_by`) on every insert and update. - For JSON/AJAX actions, always set `m_useLayout = False` and `Response.ContentType = "application/json"` at the top of the action. --- ## Recent lessons (2026-04-22) - IIS/runtime bitness matters for Access on this machine. Use 32-bit IIS Express (`%ProgramFiles(x86)%\IIS Express\iisexpress.exe`) and keep `enable32BitAppOnWin64="true"` in the active app pool. - Run migrations with 32-bit cscript: - `C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs up` - Standalone migration context (`scripts/runMigrations.vbs`) is not identical to IIS runtime migration context. `migration.DB.Execute/Query` may fail there; migrations should support fallback via `migration.Connection` + `ADODB.Command`. - `Flash_Class` API is `AddError` and `Success` property assignment. `SetError` / `SetSuccess` are invalid and cause `438`. - `KeycloakCurrentUser()` should be read defensively. Do not assume `.Exists(...)` is always available; use guarded `.Item("preferred_username")` / `.Item("email")` reads. - Shared navbar should include direct `/boards` navigation and use `Active("boards")` for active state. ## Additions to Things to avoid - Never run `scripts\runMigrations.vbs` with 64-bit cscript on this machine; use `C:\Windows\SysWOW64\cscript.exe`. - Never assume migration helper parity between IIS and standalone runner; if `migration.DB.Execute/Query` are used, provide a `migration.Connection` fallback path. - Never call `Flash().SetError` or `Flash().SetSuccess`; use `Flash().AddError` and `flash.Success = "..."`.