Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

18KB

WSC-MVC — Architecture (as implemented through M5)

Request flow

GET / or GET /hello
  -> IIS URL Rewrite rule "WscMvc-Root" (^$) or "WscMvc-Hello" (^hello/?$)
  -> /Default.asp?route=/hello
  -> Server.CreateObject("WscMvc.RequestContext"); ctx.Initialize path, httpMethod, logDir
  -> Server.CreateObject("WscMvc.Application")
  -> Application.Run(ctx, "production", viewsDir, dbConnectionString, statusLine, contentType, body, allowHeader) [Framework/Application.wsc]
       -> CreateObject("WscMvc.Router")
       -> Router.Match(ctx, "production", ..., handlerKey)          [Framework/Router.wsc]
       -> CreateObject("WscMvc.HomeController")
       -> HomeController.Hello(viewName, message)                [Controllers/HomeController.wsc]
       -> CreateObject("WscMvc.ViewRenderer")
       -> ViewRenderer.Render(viewsDir, viewName, data, body)     [Framework/ViewRenderer.wsc]
       -> LogOutcome ctx, statusLine  (best-effort append to logs/app.log)
  -> Default.asp sets Response.Status/ContentType, optional Allow header, writes body

Component boundary contract

  • Default.asp is the only file that touches ASP intrinsic objects (Request, Response, Server). It contains no business logic — only reading the route query parameter and REQUEST_METHOD, resolving logs/'s physical path via Server.MapPath, invoking WscMvc.RequestContext and WscMvc.Application, and writing the response.
  • Framework/RequestContext.wsc, Framework/Router.wsc, Framework/Application.wsc, Controllers/HomeController.wsc, and test-app/Controllers/SelfTestController.wsc never reference Request/Response/Server/Session. All data crosses the ASP-to-WSC and WSC-to-WSC boundaries as either VBScript scalars (strings) or our own RequestContext COM object (not an ASP intrinsic) — never an ASP host object. See docs/DECISIONS.md for why the original SPEC §15 open question about passing ASP intrinsics into a WSC never needed a direct experiment: the architecture never crosses that boundary by design.
  • WSC public members are exposed as plain <method> entries backed by ordinary Sub/Function procedures, never <property>/Property Get. VBScript's Property Get/Let/Set requires a Class...End Class block and cannot appear at a WSC's top-level script scope — confirmed experimentally (see docs/DECISIONS.md), not assumed from general WSC documentation.
  • Routing is an explicit literal allowlist in Framework/Router.wsc. Every call includes a fixed application name (production or tests) supplied by that app's own bootstrap; a route must match both the application and normalized path. Literal routes are checked directly before any future parameterized route support. Router decisions return either a hardcoded handler key for Application.wsc to dispatch with explicit Select Case branches, or a completed expected response (400, 404, 405 with Allow). No URL segment is ever treated as a ProgID, method name, template name, or filesystem path.
  • Route path handling is deliberately conservative in M3: ASP/IIS has already decoded the route query parameter once, so Router.wsc does not decode again. It rejects remaining % escapes, backslashes, query/fragment markers, doubled slashes, dot-segments, and control characters, then lowercases and trims one trailing slash for literal matching. The original path remains available through RequestContext.Path for logging.
  • Application.Run is the single central point that decides expected (404, ordinary control flow) vs. unexpected (COM/method failure, 500) outcomes, and the single point that logs every outcome. Default.asp still independently guards its own three sequential calls (RequestContext creation, Initialize, Application creation, Run) since a WSC failing to even instantiate happens outside Application.Run's reach.

Per-request lifetime and diagnostics

  • RequestContext and Application are both created fresh per request via Server.CreateObject in Default.asp and dropped (Set ... = Nothing) at the end of the page; neither is ever cached in ASP Session/Application scope, per SPEC §10.
  • RequestContext.Initialize generates a correlation id from Fix(Timer) plus Scripting.FileSystemObject.GetTempName() — not Randomize/Rnd(), which was tried first and shown experimentally to collide when two contexts are created within the same clock tick (see docs/DECISIONS.md).
  • Application.LogOutcome appends one line per request (timestamp, correlation id, method, path, status line, elapsed ms) to logs/app.log, guarded end-to-end by On Error Resume Next so a logging failure can never affect the HTTP response. Concurrent writers are serialized with a manual lock-file mutex (logs/app.log.lock, via CreateTextFile(..., OverwriteExisting:=False)) since no ASP intrinsic locking primitive (Application.Lock) is available to code that must not reference ASP intrinsics. The retry budget is deliberately short (see docs/DECISIONS.md): logging is explicitly best-effort and may drop lines under heavy concurrency without affecting correctness of the response.

Test harness: GET /self-test — its own app, only Framework/ is shared

test-app/Controllers/SelfTestController.wsc (WscMvc.SelfTestController) exposes framework checks over plain HTTP and returns JSON — no PowerShell, cscript, or SSH access to the VM required. Both the test site's root (GET /) and GET /self-test rewrite to the test app's /self-test route. The route table allows both GET and POST for /self-test. Production similarly maps both GET / and GET /hello to /hello, which is GET only.

This lives on its own IIS site (test-app/), not the production site. Changed 2026-09-19 at Daniel's explicit direction: a diagnostics endpoint permanently reachable on the same site/port as real traffic was the wrong shape. test-app/public/ is a second, separate IIS site's physical path (own site name, own app pool, own port, own logs/), whose web.config routes /self-test and nothing else. The production site's web.config no longer has a /self-test rewrite rule at all — GET /self-test against the production site now returns a plain 404 (nothing rewrites that path to Default.asp).

Only Framework/ is genuinely shared between the two sites — Controllers/ is not. Application.wsc/RequestContext.wsc in Framework/ are the reusable dispatch/context engine, registered once globally via COM and used by both sites. Controllers/ holds application-specific business logic: production owns Controllers/HomeController.wsc, while the test app owns test-app/Controllers/SelfTestController.wsc. COM registration for a .wsc records its exact file path, so registration tooling points each controller at its app-owned directory.

Each site also owns its thin Default.asp. The files differ only in the fixed application name passed to the shared Application.Run: production passes "production"; the test app passes "tests". That selector is part of the route match, so neither ordinary URL Rewrite nor a direct Default.asp?route=... request can activate a controller belonging to the other app. SelfTestController no longer invokes /hello or HomeController; its framework checks use the tests route set and explicitly verify that /hello is rejected.

The HTTP status is always 200 if the self-test mechanism itself ran (a broken SelfTestController/Application still degrades to the normal 500 path via the same central error handling); the JSON body's top-level "ok" field and each check's "pass" field carry the actual test results — conventional health-check design (5xx is reserved for the diagnostics mechanism being broken, not for a failed assertion inside it).

curl http://100.127.62.31:8091/self-test
{"ok":true,"checks":[{"name":"request_context_contract","pass":true,"detail":""}, ...]}

tests/run-self-test.sh wraps this in curl + python3 -m json.tool, runnable from any CLI — point it at the test-app site's URL. It also probes direct Default.asp?route=/hello and requires a 404. tests/Test-Http.ps1 tests production and, when -TestBaseUrl is supplied, verifies direct-query isolation in both directions.

SelfTestController's error details intentionally include raw Err.Description text — unlike every other route, which must never leak internals to an arbitrary caller, this route's entire purpose is diagnostics. It is a dev/test-milestone tool; reconsider whether the test-app site should be gated or removed before any production deployment during the M6 hardening pass.

COM identity

Component ProgID CLSID
Framework/RequestContext.wsc WscMvc.RequestContext {1C36FA55-34DF-4974-94B9-D657389362B2}
Framework/Router.wsc WscMvc.Router {C92F9338-B478-4EAD-B865-892FFB1E1C51}
Framework/Application.wsc WscMvc.Application {851C7763-1638-42FE-A166-BF3DD3A96A88}
Framework/ViewRenderer.wsc WscMvc.ViewRenderer {4948DF84-5DC6-448A-9F1B-EB596C28842B}
Framework/Database.wsc WscMvc.Database {E3F7C1A2-9B4D-4E6F-8C3A-2D5B7A9E1F04}
Controllers/HomeController.wsc WscMvc.HomeController {87488446-60BE-4068-8368-0B709BB68F3F}
test-app/Controllers/SelfTestController.wsc WscMvc.SelfTestController {D2634944-4646-4C55-956E-4C05E7E10904}

CLSIDs are fixed at creation and must never be recycled for a different component (AGENTS.md). Application's public Run signature changed during pre-release development (M2 added a leading ctx parameter; M3 added an allowHeader output parameter; M4 added viewsDir; M5 added dbConnectionString) under the same CLSID; acceptable because this is active pre-release (v0.1) development with exactly one caller shape (Default.asp, updated in lockstep) — not a claim that live interface changes are safe for a published/external client.

IIS sites (test host: win2025test, 100.127.62.31)

Two separate IIS sites now, both set up/reconciled by the same tools/Setup-Site.ps1 (parameterized by name/pool/path/port; idempotent, safe to re-run):

Production Test app
Site name WscMvc WscMvcTests
Port *:8090 *:8091
Physical path C:\Projects\wsc-mvc\public C:\Projects\wsc-mvc\test-app\public
App pool WscMvc WscMvcTests
Routes /hello /self-test
Logs C:\Projects\wsc-mvc\logs\app.log C:\Projects\wsc-mvc\test-app\logs\app.log

Both are 64-bit, no managed code, anonymous auth identity IUSR. Each site's public/ contains only Default.asp and web.config; every other directory (Framework/, Controllers/, test-app/Controllers/, tests/, tools/, docs/, both logs/ folders) is a sibling of whichever public/ is actually served, outside both sites’ physical paths entirely — see the “only Framework/ is shared” note above for which of those directories are truly cross-site versus app-specific. Changed 2026-09-19 at Daniel's explicit direction (see docs/DECISIONS.md); previously there was one site with the whole project directory as its root and those folders hidden via hiddenSegments, and /self-test was a route on that same site.

  • Each site's web.config keeps only a .wsc/.vbs/.ps1/.md extension denylist as defense-in-depth (in case a stray file ever lands directly in its public/); no hiddenSegments are needed since the directories they used to hide no longer exist under either served root at all.
  • enableParentPaths=true is set for both sites (scoped via appcmd ... /commit:apphost, a separate <location> block per site in applicationHost.config — not a machine-wide unlock of the locked system.webServer/asp section) so each site's Default.asp can Server.MapPath("../logs") to reach its own logs/. This setting affects only server-side script MapPath/#include resolution, not client-supplied URL paths — IIS's own URL normalization independently rejects ..-traversal in an incoming request regardless of this setting; verified with a direct request attempt, see docs/DECISIONS.md.
  • Each site's logs/ has an explicit, scoped icacls ... /grant "IIS_IUSRS:(OI)(CI)M" (now automated by tools/Setup-Site.ps1, previously a manual one-off command) so classic ASP (impersonating IUSR for anonymous requests) can write app.log. No other project directory grants IUSR/IIS_IUSRS write access — verified with a recursive icacls /T scan, see docs/DECISIONS.md.
  • Default document is Default.asp on both sites; each site's one route is served via a rewrite rule, not the default document.

Views (M4)

Framework/ViewRenderer.wsc (WscMvc.ViewRenderer) loads Views/<ViewName>.html and substitutes {{Key}} placeholders from a Scripting.Dictionary built by Application.wsc's handler subs (e.g. RunHomeHello) from a controller's plain scalar output — controllers (HomeController.Hello(viewName, message)) never build the Dictionary or touch rendering themselves, keeping them primitive-only like the rest of the request-data boundary. viewsDir is resolved by each Default.asp via Server.MapPath("../Views") (same parent-relative pattern as logDir) and threaded through Application.Run's new viewsDir parameter — ViewRenderer.wsc itself never references ASP intrinsics.

Views/ is a sibling of public/, exactly like Framework//Controllers/, so it is unreachable over HTTP by the same physical-separation guarantee (verified: GET /Views/Home.html → 404) — no additional deny rule was needed.

Substitution is a single deterministic left-to-right scan; template content is never executed as VBScript or evaluated as an expression (SPEC §8). Placeholder values are HTML-encoded (&, <, >, ", ') for text context only — attribute/URL/JS-context encoding is not implemented; this is a deliberate scope limit (SPEC §8 requires “separate, explicit handling” per context, and no current view needs anything but text context), not an oversight. An unterminated {{, a placeholder with no matching dictionary key, an invalid view name, or a missing template file are all safe errors (Err.Raise with a generic, path-free message) that flow into Application.wsc's existing central 500 handling, which already discards Err.Description entirely.

Views/Home.html is exactly {{Message}}; HomeController supplies message = "Hello from WSC-MVC!", a string with no HTML-special characters, so /hello's response body is byte-identical to the pre-M4 (M1-era) contract — the rendering pipeline was proven end-to-end without changing observable behavior. Views/Layout.html and a layout convention are deferred until a second view actually needs shared chrome (IMPLEMENTATION_PLAN M4 explicitly sequences “add layout after renderer works”).

Data (M5)

Framework/Database.wsc (WscMvc.Database) owns an explicit, request-scoped ADODB connection/transaction/command lifecycle — never cached in Session/Application (SPEC §10), never referencing ASP intrinsics. Open/Close are idempotent; BeginTransaction/CommitTransaction/RollbackTransaction give the caller explicit transaction ownership. ExecuteNonQuery(sql, paramOrder, paramsDict, rowsAffected) and ExecuteScalar(sql, paramOrder, paramsDict, result) use ADO's positional ? placeholders: paramOrder is a plain comma-separated string naming the placeholder order (not an array — deliberately avoiding an untested VBScript-array-through-WSC-<public> marshaling question), and paramsDict is a Scripting.Dictionary of name→value, the same object-parameter pattern already proven by ViewRenderer.wsc. ADODB parameter type/size are inferred from VBScript VarType()/Len() — a deliberate minimal v0.1 policy (not a general ORM type system, SPEC §3 non-goal). Null values pass through as a real SQL NULL. Every failure path (Open, command prep, execution) releases any ADODB.Command/Recordset it created and raises a generic, connection-string-free error — Database.wsc never leaks credentials or internals, matching SPEC §10.

Connection string wiring: the live connection string (with real credentials) lives only in an untracked db.connectionstring file at the project root — .gitignored, never hardcoded into any tracked .wsc/.asp/test file. Each Default.asp reads it via Server.MapPath exactly like logDir/viewsDir (production: "../db.connectionstring"; test-app: "../../db.connectionstring", since test-app/public/ sits one directory level deeper than production's public/) and threads it through Application.Run's dbConnectionString parameter. tools/Setup-TestDatabase.ps1 is the explicit, manually-run deployment step that creates the test database/schema (idempotent, same pattern as tools/Setup-Site.ps1) — never invoked from application code, per SPEC §10's “no broad filesystem/DB write privileges from a web request.”

No new production route/view was added for this milestone. SPEC §3 lists “database-backed example” as a v0.1 non-goal; M5 is proven the same way M1–M4 proved their components — direct WSH tests (tests/Test-Components.vbs) plus one diagnostic-only check added to the existing test-app SelfTestController (database_connectivity_under_app_pool_identity), which exists specifically to verify DB connectivity under the real IIS ApplicationPoolIdentity worker process, not just an interactive dev session. See docs/DECISIONS.md for why the first engine choice (LocalDB) failed that exact check and was replaced with a network-reachable SQL Server using SQL authentication (which has no dependency on the caller's Windows identity).

No route currently selects a table/column name from user input, so SPEC §9's identifier-allowlist requirement isn't yet exercised — deferred until a route actually needs it, not implemented speculatively.

Deferred to later milestones (do not implement early)

Per SPEC §3 non-goals and IMPLEMENTATION_PLAN M6+: auth, a least-privileged (non-sa) SQL login for the M5 database (see docs/DECISIONS.md), identifier allowlisting (no route needs it yet). A more robust logging mechanism (if complete coverage under concurrent load is ever required) is deferred to M6 — see the concurrency finding in docs/DECISIONS.md. A layout convention (Views/Layout.html) and non-text-context encoding are deferred until a concrete view needs them — see the Views section above.

Powered by TurnKey Linux.