A Kanban Board 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.
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
| 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 |
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 |
Routes are registered in public/Default.asp and support :param segments.
Registering routes:
' 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:
' 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:
' /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.
Plain-Old Business Object — one class per table. Generated by scripts/GenerateRepo.vbs.
<%
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:
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.Generated by scripts/GenerateRepo.vbs. Uses the DAL singleton and Automapper.
<%
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
%>
Generated by scripts/generateController.vbs. Singleton per request.
<%
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):
Dim rawJson : rawJson = GetRawJsonFromRequest()
' then parse with lib.json.asp
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:
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:
model.created_at = Now()
model.created_by = currentUsername
model.updated_at = Now()
model.updated_by = currentUsername
repo.AddNew model
Setting audit fields before update:
model.updated_at = Now()
model.updated_by = currentUsername
repo.Update model
created_at and created_by are never changed after the initial insert.
GenerateSlug(title) is already in core/helpers.asp. Always use it — never write your own slug logic.
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).
cscript //nologo scripts\generateController.vbs MyController "Index;Show(slug);Store"app/controllers/RegisterController "mycontroller"<!--#include file="MyController.asp"-->app/views/MyController/The helper in core/lib.Keycloak.asp implements the OpenID Connect authorization-code flow.
Key functions:
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.
| 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 |
Tests live in tests/ and run as a separate IIS application (never through the production public/ root).
tests/aspunit/ (vendored)run-all.asp in the test IIS apptests/test-manifest.asp — register new test pages here manuallytests/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
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:
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:
' 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.
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:
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).
Controller actions include view files via SSI (<!--#include file="..."-->). The included file's content compiles inside the controller action's Sub/Function scope. This means:
Function or Sub inside a view file — VBScript forbids nested procedure definitions. You will get Syntax error 800a03ea.Dim, ReDim, loops, and conditionals in a view are fine — only Function/Sub declarations are forbidden.Private Function in the controller class, build the result in the action, and pass it as a variable the view can reference.<%= variable %>, H(value), simple <% If / For / Do %> blocks, and includes of other partials.core/ — these are framework internals.ControllerRegistry — the MVC dispatcher will reject unregistered names.KeycloakClientSecret values — inject per environment.public/.public/ IIS app to run tests.H() when rendering user-supplied data to prevent XSS.GenerateSlug() from helpers.asp.Private Const or Public Const inside a VBScript class — it causes a syntax error. Use a Private Function that returns the value instead.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.GetRawJsonFromRequest() from helpers.asp.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.Request.BinaryRead / GetRawJsonFromRequest with Request.Form in the same action — they are mutually exclusive in Classic ASP..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().created_at, created_by, updated_at, updated_by) on every insert and update.m_useLayout = False and Response.ContentType = "application/json" at the top of the action.%ProgramFiles(x86)%\IIS Express\iisexpress.exe) and keep enable32BitAppOnWin64="true" in the active app pool.C:\Windows\SysWOW64\cscript.exe //nologo scripts\runMigrations.vbs upscripts/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./boards navigation and use Active("boards") for active state.scripts\runMigrations.vbs with 64-bit cscript on this machine; use C:\Windows\SysWOW64\cscript.exe.migration.DB.Execute/Query are used, provide a migration.Connection fallback path.Flash().SetError or Flash().SetSuccess; use Flash().AddError and flash.Success = "...".Powered by TurnKey Linux.