Adds Framework/ViewRenderer.wsc (WscMvc.ViewRenderer), loading separate
.html templates and substituting {{Key}} placeholders with HTML-encoded
values from a Scripting.Dictionary. Wires /hello through the new pipeline
(HomeController -> Application -> ViewRenderer -> Views/Home.html) while
preserving the exact pre-M4 response body, so the M1 acceptance test
doubles as a live regression check for the render pipeline.
Views/ is unreachable over HTTP by the same physical-separation guarantee
already used for Framework/ and Controllers/ (sibling of public/, never
inside it) - verified directly, not assumed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
| @@ -10,7 +10,8 @@ | |||||
| <public> | <public> | ||||
| <method name="Hello"> | <method name="Hello"> | ||||
| <parameter name="body"/> | |||||
| <parameter name="viewName"/> | |||||
| <parameter name="message"/> | |||||
| </method> | </method> | ||||
| </public> | </public> | ||||
| @@ -18,8 +19,13 @@ | |||||
| <![CDATA[ | <![CDATA[ | ||||
| Option Explicit | Option Explicit | ||||
| Sub Hello(body) | |||||
| body = "Hello from WSC-MVC!" | |||||
| ' Returns scalar view-data only (viewName + message), never rendered HTML - | |||||
| ' Application.wsc owns building the Dictionary and calling ViewRenderer, so | |||||
| ' this controller stays primitive-only like the rest of the request-data | |||||
| ' boundary (see docs/ARCHITECTURE.md). | |||||
| Sub Hello(viewName, message) | |||||
| viewName = "Home" | |||||
| message = "Hello from WSC-MVC!" | |||||
| End Sub | End Sub | ||||
| ]]> | ]]> | ||||
| </script> | </script> | ||||
| @@ -12,6 +12,7 @@ | |||||
| <method name="Run"> | <method name="Run"> | ||||
| <parameter name="ctx"/> | <parameter name="ctx"/> | ||||
| <parameter name="applicationName"/> | <parameter name="applicationName"/> | ||||
| <parameter name="viewsDir"/> | |||||
| <parameter name="statusLine"/> | <parameter name="statusLine"/> | ||||
| <parameter name="contentType"/> | <parameter name="contentType"/> | ||||
| <parameter name="body"/> | <parameter name="body"/> | ||||
| @@ -27,7 +28,7 @@ Option Explicit | |||||
| ' (COM/method failure -> 500) outcomes, and one place logs them. ctx is our | ' (COM/method failure -> 500) outcomes, and one place logs them. ctx is our | ||||
| ' own WscMvc.RequestContext object (not an ASP intrinsic), carrying only | ' own WscMvc.RequestContext object (not an ASP intrinsic), carrying only | ||||
| ' primitive request data. No ASP intrinsics are referenced here. | ' primitive request data. No ASP intrinsics are referenced here. | ||||
| Sub Run(ctx, applicationName, statusLine, contentType, body, allowHeader) | |||||
| Sub Run(ctx, applicationName, viewsDir, statusLine, contentType, body, allowHeader) | |||||
| Dim router, handlerKey | Dim router, handlerKey | ||||
| statusLine = "" | statusLine = "" | ||||
| @@ -69,7 +70,7 @@ Sub Run(ctx, applicationName, statusLine, contentType, body, allowHeader) | |||||
| Select Case handlerKey | Select Case handlerKey | ||||
| Case "Home.Hello" | Case "Home.Hello" | ||||
| RunHomeHello ctx, statusLine, contentType, body, allowHeader | |||||
| RunHomeHello ctx, viewsDir, statusLine, contentType, body, allowHeader | |||||
| Case "SelfTest.RunSelfTest" | Case "SelfTest.RunSelfTest" | ||||
| RunSelfTest ctx, statusLine, contentType, body, allowHeader | RunSelfTest ctx, statusLine, contentType, body, allowHeader | ||||
| Case Else | Case Else | ||||
| @@ -79,8 +80,8 @@ Sub Run(ctx, applicationName, statusLine, contentType, body, allowHeader) | |||||
| LogOutcome ctx, statusLine | LogOutcome ctx, statusLine | ||||
| End Sub | End Sub | ||||
| Sub RunHomeHello(ctx, statusLine, contentType, body, allowHeader) | |||||
| Dim ctrl, helloBody | |||||
| Sub RunHomeHello(ctx, viewsDir, statusLine, contentType, body, allowHeader) | |||||
| Dim ctrl, viewName, message, data, renderer, renderedBody | |||||
| Set ctrl = Nothing | Set ctrl = Nothing | ||||
| On Error Resume Next | On Error Resume Next | ||||
| @@ -93,9 +94,9 @@ Sub RunHomeHello(ctx, statusLine, contentType, body, allowHeader) | |||||
| End If | End If | ||||
| On Error Goto 0 | On Error Goto 0 | ||||
| helloBody = "" | |||||
| viewName = "" : message = "" | |||||
| On Error Resume Next | On Error Resume Next | ||||
| ctrl.Hello helloBody | |||||
| ctrl.Hello viewName, message | |||||
| If Err.Number <> 0 Then | If Err.Number <> 0 Then | ||||
| Err.Clear | Err.Clear | ||||
| On Error Goto 0 | On Error Goto 0 | ||||
| @@ -104,12 +105,32 @@ Sub RunHomeHello(ctx, statusLine, contentType, body, allowHeader) | |||||
| Exit Sub | Exit Sub | ||||
| End If | End If | ||||
| On Error Goto 0 | On Error Goto 0 | ||||
| Set ctrl = Nothing | |||||
| Set data = Nothing | |||||
| Set renderer = Nothing | |||||
| On Error Resume Next | |||||
| Set data = CreateObject("Scripting.Dictionary") | |||||
| data.Add "Message", message | |||||
| Set renderer = CreateObject("WscMvc.ViewRenderer") | |||||
| renderedBody = "" | |||||
| renderer.Render viewsDir, viewName, data, renderedBody | |||||
| If Err.Number <> 0 Then | |||||
| Err.Clear | |||||
| On Error Goto 0 | |||||
| Set data = Nothing | |||||
| Set renderer = Nothing | |||||
| InternalServerError statusLine, contentType, body, allowHeader | |||||
| Exit Sub | |||||
| End If | |||||
| On Error Goto 0 | |||||
| Set data = Nothing | |||||
| Set renderer = Nothing | |||||
| statusLine = "200 OK" | statusLine = "200 OK" | ||||
| contentType = "text/html; charset=utf-8" | contentType = "text/html; charset=utf-8" | ||||
| body = helloBody | |||||
| body = renderedBody | |||||
| allowHeader = "" | allowHeader = "" | ||||
| Set ctrl = Nothing | |||||
| End Sub | End Sub | ||||
| Sub RunSelfTest(ctx, statusLine, contentType, body, allowHeader) | Sub RunSelfTest(ctx, statusLine, contentType, body, allowHeader) | ||||
| @@ -0,0 +1,126 @@ | |||||
| <?xml version="1.0"?> | |||||
| <?component error="true" debug="false"?> | |||||
| <component> | |||||
| <registration | |||||
| description="WscMvc ViewRenderer" | |||||
| progid="WscMvc.ViewRenderer" | |||||
| version="1.00" | |||||
| classid="{4948DF84-5DC6-448A-9F1B-EB596C28842B}"> | |||||
| </registration> | |||||
| <public> | |||||
| <method name="Render"> | |||||
| <parameter name="viewsDir"/> | |||||
| <parameter name="viewName"/> | |||||
| <parameter name="data"/> | |||||
| <parameter name="output"/> | |||||
| </method> | |||||
| </public> | |||||
| <script language="VBScript"> | |||||
| <![CDATA[ | |||||
| Option Explicit | |||||
| ' Loads a separate .html template and substitutes {{Key}} placeholders with | |||||
| ' HTML-encoded values from a Scripting.Dictionary. Never references ASP | |||||
| ' intrinsics - viewsDir is a plain path string resolved by the caller (same | |||||
| ' pattern as RequestContext's logDir). Only text-context HTML encoding is | |||||
| ' implemented (SPEC SS8); attribute/URL/JS-context encoding is out of scope | |||||
| ' until a view actually needs it. Template content is never executed as | |||||
| ' VBScript or evaluated as an expression - substitution is a single literal | |||||
| ' scan-and-replace pass. | |||||
| Sub Render(viewsDir, viewName, data, output) | |||||
| Dim fso, templatePath, template, stream | |||||
| output = "" | |||||
| If Not IsValidViewName(viewName) Then | |||||
| Err.Raise vbObjectError + 1, "WscMvc.ViewRenderer", "Invalid view name" | |||||
| End If | |||||
| Set fso = CreateObject("Scripting.FileSystemObject") | |||||
| templatePath = viewsDir & "\" & viewName & ".html" | |||||
| If Not fso.FileExists(templatePath) Then | |||||
| Set fso = Nothing | |||||
| Err.Raise vbObjectError + 2, "WscMvc.ViewRenderer", "View template not found" | |||||
| End If | |||||
| Set stream = fso.OpenTextFile(templatePath, 1) ' 1 = ForReading | |||||
| template = stream.ReadAll | |||||
| stream.Close | |||||
| Set stream = Nothing | |||||
| Set fso = Nothing | |||||
| output = ExpandTemplate(template, data) | |||||
| End Sub | |||||
| Function IsValidViewName(viewName) | |||||
| Dim i, c | |||||
| IsValidViewName = False | |||||
| If IsNull(viewName) Or IsEmpty(viewName) Then Exit Function | |||||
| If Len(viewName) = 0 Then Exit Function | |||||
| For i = 1 To Len(viewName) | |||||
| c = Mid(viewName, i, 1) | |||||
| If Not ((c >= "A" And c <= "Z") Or (c >= "a" And c <= "z") Or _ | |||||
| (c >= "0" And c <= "9") Or c = "_" Or c = "-") Then | |||||
| Exit Function | |||||
| End If | |||||
| Next | |||||
| IsValidViewName = True | |||||
| End Function | |||||
| ' Single left-to-right scan for {{Key}} tokens. Unterminated "{{" and a key | |||||
| ' with no matching dictionary entry are both treated as template-authoring | |||||
| ' errors (Err.Raise) rather than emitted as literal text, so rendered output | |||||
| ' is always fully resolved - never partially-substituted markup. | |||||
| Function ExpandTemplate(template, data) | |||||
| Dim result, pos, openPos, closePos, key, hasKey | |||||
| result = "" | |||||
| pos = 1 | |||||
| Do | |||||
| openPos = InStr(pos, template, "{{") | |||||
| If openPos = 0 Then | |||||
| result = result & Mid(template, pos) | |||||
| Exit Do | |||||
| End If | |||||
| result = result & Mid(template, pos, openPos - pos) | |||||
| closePos = InStr(openPos + 2, template, "}}") | |||||
| If closePos = 0 Then | |||||
| Err.Raise vbObjectError + 3, "WscMvc.ViewRenderer", "Unterminated view placeholder" | |||||
| End If | |||||
| key = Trim(Mid(template, openPos + 2, closePos - openPos - 2)) | |||||
| hasKey = False | |||||
| If Not data Is Nothing Then | |||||
| If data.Exists(key) Then hasKey = True | |||||
| End If | |||||
| If Not hasKey Then | |||||
| Err.Raise vbObjectError + 4, "WscMvc.ViewRenderer", "Unresolved view placeholder" | |||||
| End If | |||||
| result = result & HtmlEncode(CStr(data(key))) | |||||
| pos = closePos + 2 | |||||
| Loop | |||||
| ExpandTemplate = result | |||||
| End Function | |||||
| Function HtmlEncode(s) | |||||
| Dim r | |||||
| r = s | |||||
| r = Replace(r, "&", "&") | |||||
| r = Replace(r, "<", "<") | |||||
| r = Replace(r, ">", ">") | |||||
| r = Replace(r, """", """) | |||||
| r = Replace(r, "'", "'") | |||||
| HtmlEncode = r | |||||
| End Function | |||||
| ]]> | |||||
| </script> | |||||
| </component> | |||||
| @@ -32,9 +32,9 @@ Gate: repeated/concurrent requests and failure cases behave predictably. **MET** | |||||
| Gate: route matrix and malformed URL tests pass. **MET** — see `docs/TEST-RESULTS.md`. WSH covers full Router/Application routing contract; IIS HTTP covers GET/POST-routed behavior and malformed route handling. PUT/DELETE can be intercepted by IIS before Classic ASP on the tested host, so those are not treated as framework HTTP-route assertions. | Gate: route matrix and malformed URL tests pass. **MET** — see `docs/TEST-RESULTS.md`. WSH covers full Router/Application routing contract; IIS HTTP covers GET/POST-routed behavior and malformed route handling. PUT/DELETE can be intercepted by IIS before Classic ASP on the tested host, so those are not treated as framework HTTP-route assertions. | ||||
| ## M4 — Views | ## M4 — Views | ||||
| - [ ] Safe separate template loading, text-context encoding, default-deny for private templates. | |||||
| - [ ] Template missing/escaping tests; add layout after renderer works. | |||||
| Gate: output is deterministic and untrusted text does not become HTML. | |||||
| - [x] Safe separate template loading, text-context encoding, default-deny for private templates. | |||||
| - [x] Template missing/escaping tests; add layout after renderer works. | |||||
| Gate: output is deterministic and untrusted text does not become HTML. **MET** for the vertical slice shipped — see `docs/TEST-RESULTS.md`. `Views/Layout.html` (a layout convention) is not yet added since there is still only one view; add it when a second view needs shared chrome, not speculatively. | |||||
| ## M5 — Data | ## M5 — Data | ||||
| - [ ] ADODB contract and provider-specific integration test database. | - [ ] ADODB contract and provider-specific integration test database. | ||||
| @@ -0,0 +1 @@ | |||||
| {{Message}} | |||||
| @@ -12,7 +12,9 @@ GET / or GET /hello | |||||
| -> CreateObject("WscMvc.Router") | -> CreateObject("WscMvc.Router") | ||||
| -> Router.Match(ctx, "production", ..., handlerKey) [Framework/Router.wsc] | -> Router.Match(ctx, "production", ..., handlerKey) [Framework/Router.wsc] | ||||
| -> CreateObject("WscMvc.HomeController") | -> CreateObject("WscMvc.HomeController") | ||||
| -> HomeController.Hello(body) [Controllers/HomeController.wsc] | |||||
| -> 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) | -> LogOutcome ctx, statusLine (best-effort append to logs/app.log) | ||||
| -> Default.asp sets Response.Status/ContentType, optional Allow header, writes body | -> Default.asp sets Response.Status/ContentType, optional Allow header, writes body | ||||
| ``` | ``` | ||||
| @@ -60,6 +62,7 @@ curl http://100.127.62.31:8091/self-test | |||||
| | `Framework/RequestContext.wsc` | `WscMvc.RequestContext` | `{1C36FA55-34DF-4974-94B9-D657389362B2}` | | | `Framework/RequestContext.wsc` | `WscMvc.RequestContext` | `{1C36FA55-34DF-4974-94B9-D657389362B2}` | | ||||
| | `Framework/Router.wsc` | `WscMvc.Router` | `{C92F9338-B478-4EAD-B865-892FFB1E1C51}` | | | `Framework/Router.wsc` | `WscMvc.Router` | `{C92F9338-B478-4EAD-B865-892FFB1E1C51}` | | ||||
| | `Framework/Application.wsc` | `WscMvc.Application` | `{851C7763-1638-42FE-A166-BF3DD3A96A88}` | | | `Framework/Application.wsc` | `WscMvc.Application` | `{851C7763-1638-42FE-A166-BF3DD3A96A88}` | | ||||
| | `Framework/ViewRenderer.wsc` | `WscMvc.ViewRenderer` | `{4948DF84-5DC6-448A-9F1B-EB596C28842B}` | | |||||
| | `Controllers/HomeController.wsc` | `WscMvc.HomeController` | `{87488446-60BE-4068-8368-0B709BB68F3F}` | | | `Controllers/HomeController.wsc` | `WscMvc.HomeController` | `{87488446-60BE-4068-8368-0B709BB68F3F}` | | ||||
| | `test-app/Controllers/SelfTestController.wsc` | `WscMvc.SelfTestController` | `{D2634944-4646-4C55-956E-4C05E7E10904}` | | | `test-app/Controllers/SelfTestController.wsc` | `WscMvc.SelfTestController` | `{D2634944-4646-4C55-956E-4C05E7E10904}` | | ||||
| @@ -85,6 +88,16 @@ Both are 64-bit, no managed code, anonymous auth identity `IUSR`. Each site's `p | |||||
| - 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`. | - 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. | - 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"). | |||||
| ## Deferred to later milestones (do not implement early) | ## Deferred to later milestones (do not implement early) | ||||
| Per SPEC §3 non-goals and IMPLEMENTATION_PLAN M4+: HTML views/templates, ADODB, auth. 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`. | |||||
| Per SPEC §3 non-goals and IMPLEMENTATION_PLAN M5+: ADODB, auth. 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. | |||||
| @@ -117,3 +117,13 @@ Route path validation is intentionally conservative for M3. ASP/IIS has already | |||||
| The response contract gained an `allowHeader` out parameter from `Application.Run`. `Default.asp` is still the only layer that touches ASP `Response`; it emits the `Allow` header only when the framework returns one. This keeps routing/framework code ASP-intrinsic-free while still allowing correct HTTP behavior for app-routed `405 Method Not Allowed` responses. | The response contract gained an `allowHeader` out parameter from `Application.Run`. `Default.asp` is still the only layer that touches ASP `Response`; it emits the `Allow` header only when the framework returns one. This keeps routing/framework code ASP-intrinsic-free while still allowing correct HTTP behavior for app-routed `405 Method Not Allowed` responses. | ||||
| IIS/Classic ASP on the local Windows 11 test host intercepted `PUT`/`DELETE` requests before they reached the application, returning IIS's own `Allow: GET, HEAD, OPTIONS, TRACE`. Therefore HTTP integration tests assert framework 405 behavior using `POST /hello`, which Classic ASP does deliver to the app and which returns the framework's `Allow: GET`. The full Router/Application unsupported-method contract, including `DELETE /self-test -> Allow: GET, POST`, is covered by WSH component tests and the self-test controller's direct `Router.Match` checks. This matches the M3 scope: support GET and POST initially; distinguish unsupported methods where the request reaches the framework. | IIS/Classic ASP on the local Windows 11 test host intercepted `PUT`/`DELETE` requests before they reached the application, returning IIS's own `Allow: GET, HEAD, OPTIONS, TRACE`. Therefore HTTP integration tests assert framework 405 behavior using `POST /hello`, which Classic ASP does deliver to the app and which returns the framework's `Allow: GET`. The full Router/Application unsupported-method contract, including `DELETE /self-test -> Allow: GET, POST`, is covered by WSH component tests and the self-test controller's direct `Router.Match` checks. This matches the M3 scope: support GET and POST initially; distinguish unsupported methods where the request reaches the framework. | ||||
| ## M4 — Views: ViewRenderer, controller/render split (2026-09-19) | |||||
| Added `Framework/ViewRenderer.wsc` (`WscMvc.ViewRenderer`) and `Views/Home.html`. Design choice: controllers (`HomeController.Hello`) stay primitive-only (return `viewName`, `message` as plain strings) and `Application.wsc`'s handler subs own building the `Scripting.Dictionary` and calling `ViewRenderer.Render` — matches the existing pattern of keeping business-logic components decoupled from any object more complex than what they strictly need to hand back, and avoids deciding a controller-to-Dictionary contract before a second controller actually needs one. `viewsDir` is threaded through as a new `Application.Run` parameter (not through `RequestContext`, which would have cascaded into every `ctx.Initialize` call site across both `Default.asp` files, `SelfTestController.wsc` (5 call sites), and the WSH tests, for a value that is pure Framework wiring rather than genuine per-request data) — smallest-footprint choice given `Application.Run`'s signature is already established as changeable pre-release (see the M2/M3 CLSID-stability note above). | |||||
| `/hello`'s existing exact-body contract (`"Hello from WSC-MVC!"`, fixed since M1/SPEC §4) was deliberately preserved rather than changed to prove the render pipeline: `Views/Home.html` is exactly `{{Message}}`, and the message string has no HTML-special characters, so HTML-encoding is a no-op and the response is byte-identical pre- and post-M4 — verified directly (`Content-Length: 19` unchanged) rather than assumed. This let the M1 acceptance test double as a live M4 regression check instead of requiring a new observable behavior to prove the slice works. | |||||
| No experimental surprises this milestone (unlike M0-M3, which each surfaced a real WSC/VBScript defect) — returning a newly-created object through a `Sub`'s `ByRef` out-parameter (`ctrl.Hello viewName, message`, both scalars in this case, but the same mechanism was also exercised for `Set data = CreateObject(...)` inside `RunHomeHello`) behaved exactly as ordinary VBScript `ByRef` semantics predict, and `Err.Raise` inside a WSC method, caught by the caller's existing `On Error Resume Next` + `Err.Number` pattern, worked identically to the naturally-thrown errors (`CreateObject` failures) already relied on elsewhere. Confirmed via the WSH suite's dedicated `ViewRenderer` contract tests (encoding, missing template, unresolved placeholder, invalid view name) before trusting it in `Application.wsc`. | |||||
| Confirmed `Views/` is unreachable over HTTP purely from being a sibling of `public/` (same guarantee already used for `Framework/`/`Controllers/`) — no new `web.config` rule was needed: `GET /Views/Home.html` → `404` on both sites. | |||||
| @@ -354,3 +354,68 @@ Result: **PASS** — `ok=true`, checks included `request_context_contract`, `cor | |||||
| `tests/run-self-test.sh` local note: first failed under local bash because the Windows checkout had CRLF line endings in the `.sh` file (`set: pipefail\r: invalid option name`). Added `.gitattributes` (`*.sh text eol=lf`) and normalized the script. After that, local WSL/bash still could not reach Windows IIS on `localhost:8091`/gateway IP from this environment (`curl: (7) Failed to connect`), so the bash wrapper is **NOT RUN/PASS locally**. The same HTTP JSON contract was verified with PowerShell above; the bash wrapper remains intended for a CLI that can reach the test-app URL, as in earlier VM/tailnet evidence. | `tests/run-self-test.sh` local note: first failed under local bash because the Windows checkout had CRLF line endings in the `.sh` file (`set: pipefail\r: invalid option name`). Added `.gitattributes` (`*.sh text eol=lf`) and normalized the script. After that, local WSL/bash still could not reach Windows IIS on `localhost:8091`/gateway IP from this environment (`curl: (7) Failed to connect`), so the bash wrapper is **NOT RUN/PASS locally**. The same HTTP JSON contract was verified with PowerShell above; the bash wrapper remains intended for a CLI that can reach the test-app URL, as in earlier VM/tailnet evidence. | ||||
| M3 gate status: **PASS** for framework and IIS GET/POST behavior. PUT/DELETE HTTP probes are not used as app-route assertions on this host because IIS intercepts them before Classic ASP; WSH/self-test Router checks cover the unsupported-method framework contract directly. | M3 gate status: **PASS** for framework and IIS GET/POST behavior. PUT/DELETE HTTP probes are not used as app-route assertions on this host because IIS intercepts them before Classic ASP; WSH/self-test Router checks cover the unsupported-method framework contract directly. | ||||
| ## M4 — Views: separate templates, text-context encoding, default-deny (2026-09-19) | |||||
| Host under test: `DESKTOP-80D128R`, Microsoft Windows 11 Pro 10.0.26200, 64-bit — same local IIS sites as M3 (`WscMvc` on `:8090`, `WscMvcTests` on `:8091`), already running from this checkout. | |||||
| Added `Framework/ViewRenderer.wsc` (`WscMvc.ViewRenderer`, `{4948DF84-5DC6-448A-9F1B-EB596C28842B}`) and `Views/Home.html` (`{{Message}}`). `Controllers/HomeController.wsc`'s `Hello` now returns scalar view data (`viewName`, `message`) instead of a finished body string; `Framework/Application.wsc`'s `Run` gained a `viewsDir` parameter and `RunHomeHello` now builds a `Scripting.Dictionary` and calls `ViewRenderer.Render`. Both `Default.asp` files resolve `viewsDir = Server.MapPath("../Views")`, the same parent-relative pattern already used for `logDir`. | |||||
| Test-first check before implementation: | |||||
| ``` | |||||
| cscript //nologo tests\Test-Components.vbs | |||||
| ``` | |||||
| Result before M4 implementation: **FAIL**, as expected for the new contract: | |||||
| ``` | |||||
| FAIL: Application.Run raised error on /hello - Wrong number of arguments or invalid property assignment | |||||
| FAIL: could not create WscMvc.ViewRenderer - ActiveX component can't create object | |||||
| RESULT: FAILURE | |||||
| ``` | |||||
| Registration after implementation: | |||||
| ``` | |||||
| powershell -ExecutionPolicy Bypass -File tools\Register-Components.ps1 | |||||
| ``` | |||||
| Result: **PASS** — registered `RequestContext`, `Router`, new `ViewRenderer`, `Application`, `HomeController`, `SelfTestController`. | |||||
| WSH component contract: | |||||
| ``` | |||||
| cscript //nologo tests\Test-Components.vbs | |||||
| ``` | |||||
| Result: **PASS** (full output, ViewRenderer-specific lines): | |||||
| ``` | |||||
| PASS: Application.Run(/hello) body [unchanged: "Hello from WSC-MVC!" — regression proof the render pipeline preserves M1's exact-body contract] | |||||
| PASS: ViewRenderer HTML-encodes placeholder value | |||||
| PASS: ViewRenderer passes through template with no placeholders (data=Nothing) | |||||
| PASS: ViewRenderer.Render on missing template raises a path-free error | |||||
| PASS: ViewRenderer.Render raises on unresolved placeholder | |||||
| PASS: ViewRenderer.Render rejects an invalid view name | |||||
| RESULT: ALL PASS | |||||
| ``` | |||||
| HTTP integration: | |||||
| ``` | |||||
| powershell -ExecutionPolicy Bypass -File tests\Test-Http.ps1 -BaseUrl http://localhost:8090 -TestBaseUrl http://localhost:8091 | |||||
| ``` | |||||
| Result: **PASS**, all 14 existing checks including `GET /hello body` (unchanged) and `GET /Framework/Application.wsc unreachable`. | |||||
| Direct verification that `Views/` is unreachable over HTTP (physical separation, same guarantee as `Framework/`/`Controllers/` — `Views/` is a sibling of `public/`, never inside it): | |||||
| ``` | |||||
| curl -i http://localhost:8090/hello -> 200, "Hello from WSC-MVC!" (Content-Length: 19, unchanged from pre-M4) | |||||
| curl -o /dev/null -w "%{http_code}" http://localhost:8090/Views/Home.html -> 404 | |||||
| ``` | |||||
| `tests/run-self-test.sh http://localhost:8091`: **NOT RUN locally** — this host's local Python is a Microsoft Store execution-alias stub, so the script's `json.tool` pretty-print step cannot run. The same JSON contract was already verified directly (`curl http://localhost:8091/self-test` → `{"ok":true,...}`, all 5 checks passing) and via `Test-Http.ps1` above; this is a convenience-script limitation on this specific host, not a framework regression — matches the pre-existing NOT RUN note for this same script under M3. | |||||
| M4 gate status: **PASS** for the vertical slice implemented (safe separate template loading from a physically non-served directory; deterministic `{{Key}}` substitution; default HTML-text-context encoding verified against a real escaping case; missing-template and unresolved-placeholder failures verified path-free and safe; `/hello`'s existing exact-body contract verified unchanged end-to-end through IIS). `Views/Layout.html` and a layout convention are deferred (IMPLEMENTATION_PLAN M4 sequences "add layout after renderer works" as a second step) — not yet needed since there is still only one view. Attribute/URL/JS-context encoding remains explicitly out of scope until a view needs it (documented on `ViewRenderer.wsc` and in `docs/ARCHITECTURE.md`). | |||||
| @@ -1,15 +1,16 @@ | |||||
| <%@ Language="VBScript" %> | <%@ Language="VBScript" %> | ||||
| <% Option Explicit %> | <% Option Explicit %> | ||||
| <% | <% | ||||
| Dim route, httpMethod, logDir, ctx, app, applicationName, statusLine, contentType, body, allowHeader | |||||
| Dim route, httpMethod, logDir, viewsDir, ctx, app, applicationName, statusLine, contentType, body, allowHeader | |||||
| route = Request.QueryString("route") | route = Request.QueryString("route") | ||||
| httpMethod = Request.ServerVariables("REQUEST_METHOD") | httpMethod = Request.ServerVariables("REQUEST_METHOD") | ||||
| applicationName = "production" | applicationName = "production" | ||||
| ' logs/ deliberately lives outside the served "public" webroot (IIS site | |||||
| ' physical path = public/), so a parent-relative MapPath is required here - | |||||
| ' logs/ and Views/ deliberately live outside the served "public" webroot (IIS | |||||
| ' site physical path = public/), so parent-relative MapPath is required here - | |||||
| ' requires enableParentPaths=true for this site. See docs/DECISIONS.md. | ' requires enableParentPaths=true for this site. See docs/DECISIONS.md. | ||||
| logDir = Server.MapPath("../logs") | logDir = Server.MapPath("../logs") | ||||
| viewsDir = Server.MapPath("../Views") | |||||
| Set ctx = Nothing | Set ctx = Nothing | ||||
| On Error Resume Next | On Error Resume Next | ||||
| @@ -57,7 +58,7 @@ body = "" | |||||
| allowHeader = "" | allowHeader = "" | ||||
| On Error Resume Next | On Error Resume Next | ||||
| app.Run ctx, applicationName, statusLine, contentType, body, allowHeader | |||||
| app.Run ctx, applicationName, viewsDir, statusLine, contentType, body, allowHeader | |||||
| If Err.Number <> 0 Then | If Err.Number <> 0 Then | ||||
| Err.Clear | Err.Clear | ||||
| On Error Goto 0 | On Error Goto 0 | ||||
| @@ -87,7 +87,7 @@ Sub RunSelfTest(logDir, body) | |||||
| Set ctxUnknown = CreateObject("WscMvc.RequestContext") | Set ctxUnknown = CreateObject("WscMvc.RequestContext") | ||||
| ctxUnknown.Initialize "/hello", "GET", logDir | ctxUnknown.Initialize "/hello", "GET", logDir | ||||
| unkStatus = "" : unkType = "" : unkBody = "" : unkAllow = "" | unkStatus = "" : unkType = "" : unkBody = "" : unkAllow = "" | ||||
| app.Run ctxUnknown, "tests", unkStatus, unkType, unkBody, unkAllow | |||||
| app.Run ctxUnknown, "tests", "", unkStatus, unkType, unkBody, unkAllow | |||||
| If Err.Number <> 0 Then | If Err.Number <> 0 Then | ||||
| unkDetail = Err.Description | unkDetail = Err.Description | ||||
| Err.Clear | Err.Clear | ||||
| @@ -5,15 +5,16 @@ | |||||
| ' set in the shared framework. Production's bootstrap selects "production". | ' set in the shared framework. Production's bootstrap selects "production". | ||||
| ' Keeping this value inside each app prevents direct Default.asp?route=... | ' Keeping this value inside each app prevents direct Default.asp?route=... | ||||
| ' requests from crossing the application boundary. | ' requests from crossing the application boundary. | ||||
| Dim route, httpMethod, logDir, ctx, app, applicationName, statusLine, contentType, body, allowHeader | |||||
| Dim route, httpMethod, logDir, viewsDir, ctx, app, applicationName, statusLine, contentType, body, allowHeader | |||||
| route = Request.QueryString("route") | route = Request.QueryString("route") | ||||
| httpMethod = Request.ServerVariables("REQUEST_METHOD") | httpMethod = Request.ServerVariables("REQUEST_METHOD") | ||||
| applicationName = "tests" | applicationName = "tests" | ||||
| ' logs/ deliberately lives outside the served "public" webroot (IIS site | |||||
| ' physical path = public/), so a parent-relative MapPath is required here - | |||||
| ' logs/ and Views/ deliberately live outside the served "public" webroot (IIS | |||||
| ' site physical path = public/), so parent-relative MapPath is required here - | |||||
| ' requires enableParentPaths=true for this site. See docs/DECISIONS.md. | ' requires enableParentPaths=true for this site. See docs/DECISIONS.md. | ||||
| logDir = Server.MapPath("../logs") | logDir = Server.MapPath("../logs") | ||||
| viewsDir = Server.MapPath("../Views") | |||||
| Set ctx = Nothing | Set ctx = Nothing | ||||
| On Error Resume Next | On Error Resume Next | ||||
| @@ -61,7 +62,7 @@ body = "" | |||||
| allowHeader = "" | allowHeader = "" | ||||
| On Error Resume Next | On Error Resume Next | ||||
| app.Run ctx, applicationName, statusLine, contentType, body, allowHeader | |||||
| app.Run ctx, applicationName, viewsDir, statusLine, contentType, body, allowHeader | |||||
| If Err.Number <> 0 Then | If Err.Number <> 0 Then | ||||
| Err.Clear | Err.Clear | ||||
| On Error Goto 0 | On Error Goto 0 | ||||
| @@ -1,6 +1,6 @@ | |||||
| Option Explicit | Option Explicit | ||||
| Dim fso, logDir, pass | |||||
| Dim fso, logDir, viewsDir, testViewsDir, pass | |||||
| pass = True | pass = True | ||||
| Set fso = CreateObject("Scripting.FileSystemObject") | Set fso = CreateObject("Scripting.FileSystemObject") | ||||
| @@ -9,6 +9,17 @@ If fso.FolderExists(logDir) Then | |||||
| fso.DeleteFolder logDir, True | fso.DeleteFolder logDir, True | ||||
| End If | End If | ||||
| ' Real Views/ used for the /hello integration path (proves the end-to-end | |||||
| ' rendering pipeline, not just the ViewRenderer component in isolation). | |||||
| viewsDir = fso.GetParentFolderName(WScript.ScriptFullName) & "\..\Views" | |||||
| ' Disposable fixture directory for direct ViewRenderer contract tests below. | |||||
| testViewsDir = fso.GetParentFolderName(WScript.ScriptFullName) & "\test-views" | |||||
| If fso.FolderExists(testViewsDir) Then | |||||
| fso.DeleteFolder testViewsDir, True | |||||
| End If | |||||
| fso.CreateFolder testViewsDir | |||||
| Function NewContext(path, httpMethod) | Function NewContext(path, httpMethod) | ||||
| Dim ctx | Dim ctx | ||||
| Set ctx = CreateObject("WscMvc.RequestContext") | Set ctx = CreateObject("WscMvc.RequestContext") | ||||
| @@ -20,10 +31,18 @@ Sub RunApplication(app, path, httpMethod, applicationName, statusLine, contentTy | |||||
| Dim ctx | Dim ctx | ||||
| Set ctx = NewContext(path, httpMethod) | Set ctx = NewContext(path, httpMethod) | ||||
| statusLine = "" : contentType = "" : body = "" : allowHeader = "" | statusLine = "" : contentType = "" : body = "" : allowHeader = "" | ||||
| app.Run ctx, applicationName, statusLine, contentType, body, allowHeader | |||||
| app.Run ctx, applicationName, viewsDir, statusLine, contentType, body, allowHeader | |||||
| Set ctx = Nothing | Set ctx = Nothing | ||||
| End Sub | End Sub | ||||
| Sub WriteFixture(fileName, content) | |||||
| Dim stream | |||||
| Set stream = fso.CreateTextFile(testViewsDir & "\" & fileName, True) | |||||
| stream.Write content | |||||
| stream.Close | |||||
| Set stream = Nothing | |||||
| End Sub | |||||
| Sub CheckEqual(actual, expected, label) | Sub CheckEqual(actual, expected, label) | ||||
| If actual = expected Then | If actual = expected Then | ||||
| WScript.Echo "PASS: " & label | WScript.Echo "PASS: " & label | ||||
| @@ -177,6 +196,123 @@ If pass Then | |||||
| CheckEqual allowHeader, "", "Application.Run(malformed path) Allow header" | CheckEqual allowHeader, "", "Application.Run(malformed path) Allow header" | ||||
| End If | End If | ||||
| ' --- ViewRenderer contract: substitution + HTML-encoding --- | |||||
| Dim renderer | |||||
| On Error Resume Next | |||||
| Set renderer = CreateObject("WscMvc.ViewRenderer") | |||||
| If Err.Number <> 0 Then | |||||
| WScript.Echo "FAIL: could not create WscMvc.ViewRenderer - " & Err.Description | |||||
| pass = False | |||||
| Err.Clear | |||||
| End If | |||||
| On Error Goto 0 | |||||
| If pass Then | |||||
| WriteFixture "Sample.html", "<p>{{Name}}</p>" | |||||
| Dim dataSample, outSample | |||||
| Set dataSample = CreateObject("Scripting.Dictionary") | |||||
| dataSample.Add "Name", "<script>alert(1)</script>" | |||||
| outSample = "" | |||||
| On Error Resume Next | |||||
| renderer.Render testViewsDir, "Sample", dataSample, outSample | |||||
| If Err.Number <> 0 Then | |||||
| WScript.Echo "FAIL: ViewRenderer.Render raised error on Sample - " & Err.Description | |||||
| pass = False | |||||
| Err.Clear | |||||
| End If | |||||
| On Error Goto 0 | |||||
| If pass Then | |||||
| CheckEqual outSample, "<p><script>alert(1)</script></p>", "ViewRenderer HTML-encodes placeholder value" | |||||
| End If | |||||
| Set dataSample = Nothing | |||||
| End If | |||||
| ' --- ViewRenderer contract: template with no placeholders passes through unchanged, data = Nothing --- | |||||
| If pass Then | |||||
| WriteFixture "Plain.html", "Plain text, no placeholders." | |||||
| Dim outPlain | |||||
| outPlain = "" | |||||
| On Error Resume Next | |||||
| renderer.Render testViewsDir, "Plain", Nothing, outPlain | |||||
| If Err.Number <> 0 Then | |||||
| WScript.Echo "FAIL: ViewRenderer.Render raised error on Plain (data=Nothing) - " & Err.Description | |||||
| pass = False | |||||
| Err.Clear | |||||
| End If | |||||
| On Error Goto 0 | |||||
| If pass Then | |||||
| CheckEqual outPlain, "Plain text, no placeholders.", "ViewRenderer passes through template with no placeholders (data=Nothing)" | |||||
| End If | |||||
| End If | |||||
| ' --- ViewRenderer contract: missing template file is a safe, path-free error --- | |||||
| If pass Then | |||||
| Dim outMissing, missingOk, missingDetail | |||||
| missingOk = False | |||||
| outMissing = "" | |||||
| On Error Resume Next | |||||
| renderer.Render testViewsDir, "DoesNotExist", Nothing, outMissing | |||||
| If Err.Number <> 0 Then | |||||
| missingDetail = Err.Description | |||||
| If InStr(1, missingDetail, testViewsDir, 1) = 0 Then | |||||
| missingOk = True | |||||
| End If | |||||
| Err.Clear | |||||
| End If | |||||
| On Error Goto 0 | |||||
| If missingOk Then | |||||
| WScript.Echo "PASS: ViewRenderer.Render on missing template raises a path-free error" | |||||
| Else | |||||
| WScript.Echo "FAIL: ViewRenderer.Render on missing template -> Err.Number after call, detail=[" & missingDetail & "]" | |||||
| pass = False | |||||
| End If | |||||
| End If | |||||
| ' --- ViewRenderer contract: unresolved placeholder (no matching dictionary key) is a safe error --- | |||||
| If pass Then | |||||
| WriteFixture "Missing.html", "{{Unknown}}" | |||||
| Dim dataEmpty, outMissingKey, unresolvedOk | |||||
| Set dataEmpty = CreateObject("Scripting.Dictionary") | |||||
| unresolvedOk = False | |||||
| outMissingKey = "" | |||||
| On Error Resume Next | |||||
| renderer.Render testViewsDir, "Missing", dataEmpty, outMissingKey | |||||
| If Err.Number <> 0 Then | |||||
| unresolvedOk = True | |||||
| Err.Clear | |||||
| End If | |||||
| On Error Goto 0 | |||||
| If unresolvedOk Then | |||||
| WScript.Echo "PASS: ViewRenderer.Render raises on unresolved placeholder" | |||||
| Else | |||||
| WScript.Echo "FAIL: ViewRenderer.Render did not raise on unresolved placeholder -> got [" & outMissingKey & "]" | |||||
| pass = False | |||||
| End If | |||||
| Set dataEmpty = Nothing | |||||
| End If | |||||
| ' --- ViewRenderer contract: invalid view name (path traversal attempt) is rejected --- | |||||
| If pass Then | |||||
| Dim outTraversal, traversalOk | |||||
| traversalOk = False | |||||
| outTraversal = "" | |||||
| On Error Resume Next | |||||
| renderer.Render testViewsDir, "../secret", Nothing, outTraversal | |||||
| If Err.Number <> 0 Then | |||||
| traversalOk = True | |||||
| Err.Clear | |||||
| End If | |||||
| On Error Goto 0 | |||||
| If traversalOk Then | |||||
| WScript.Echo "PASS: ViewRenderer.Render rejects an invalid view name" | |||||
| Else | |||||
| WScript.Echo "FAIL: ViewRenderer.Render did not reject invalid view name -> got [" & outTraversal & "]" | |||||
| pass = False | |||||
| End If | |||||
| End If | |||||
| Set renderer = Nothing | |||||
| ' --- Logging: best-effort log file was written with both outcomes --- | ' --- Logging: best-effort log file was written with both outcomes --- | ||||
| If pass Then | If pass Then | ||||
| Dim logPath, logContent | Dim logPath, logContent | ||||
| @@ -205,6 +341,9 @@ Set ctx2 = Nothing | |||||
| If fso.FolderExists(logDir) Then | If fso.FolderExists(logDir) Then | ||||
| fso.DeleteFolder logDir, True | fso.DeleteFolder logDir, True | ||||
| End If | End If | ||||
| If fso.FolderExists(testViewsDir) Then | |||||
| fso.DeleteFolder testViewsDir, True | |||||
| End If | |||||
| Set fso = Nothing | Set fso = Nothing | ||||
| If pass Then | If pass Then | ||||
| @@ -14,6 +14,7 @@ $components = @( | |||||
| # docs/ARCHITECTURE.md. | # docs/ARCHITECTURE.md. | ||||
| (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'), | (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'), | ||||
| (Join-Path $ProjectRoot 'Framework\Router.wsc'), | (Join-Path $ProjectRoot 'Framework\Router.wsc'), | ||||
| (Join-Path $ProjectRoot 'Framework\ViewRenderer.wsc'), | |||||
| (Join-Path $ProjectRoot 'Framework\Application.wsc'), | (Join-Path $ProjectRoot 'Framework\Application.wsc'), | ||||
| (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'), | (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'), | ||||
| (Join-Path $ProjectRoot 'test-app\Controllers\SelfTestController.wsc') | (Join-Path $ProjectRoot 'test-app\Controllers\SelfTestController.wsc') | ||||
| @@ -18,6 +18,7 @@ $components = @( | |||||
| @{ Path = (Join-Path $ProjectRoot 'test-app\Controllers\SelfTestController.wsc'); ProgId = 'WscMvc.SelfTestController'; ClassId = '{D2634944-4646-4C55-956E-4C05E7E10904}' }, | @{ Path = (Join-Path $ProjectRoot 'test-app\Controllers\SelfTestController.wsc'); ProgId = 'WscMvc.SelfTestController'; ClassId = '{D2634944-4646-4C55-956E-4C05E7E10904}' }, | ||||
| @{ Path = (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'); ProgId = 'WscMvc.HomeController'; ClassId = '{87488446-60BE-4068-8368-0B709BB68F3F}' }, | @{ Path = (Join-Path $ProjectRoot 'Controllers\HomeController.wsc'); ProgId = 'WscMvc.HomeController'; ClassId = '{87488446-60BE-4068-8368-0B709BB68F3F}' }, | ||||
| @{ Path = (Join-Path $ProjectRoot 'Framework\Application.wsc'); ProgId = 'WscMvc.Application'; ClassId = '{851C7763-1638-42FE-A166-BF3DD3A96A88}' }, | @{ Path = (Join-Path $ProjectRoot 'Framework\Application.wsc'); ProgId = 'WscMvc.Application'; ClassId = '{851C7763-1638-42FE-A166-BF3DD3A96A88}' }, | ||||
| @{ Path = (Join-Path $ProjectRoot 'Framework\ViewRenderer.wsc'); ProgId = 'WscMvc.ViewRenderer'; ClassId = '{4948DF84-5DC6-448A-9F1B-EB596C28842B}' }, | |||||
| @{ Path = (Join-Path $ProjectRoot 'Framework\Router.wsc'); ProgId = 'WscMvc.Router'; ClassId = '{C92F9338-B478-4EAD-B865-892FFB1E1C51}' }, | @{ Path = (Join-Path $ProjectRoot 'Framework\Router.wsc'); ProgId = 'WscMvc.Router'; ClassId = '{C92F9338-B478-4EAD-B865-892FFB1E1C51}' }, | ||||
| @{ Path = (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'); ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' } | @{ Path = (Join-Path $ProjectRoot 'Framework\RequestContext.wsc'); ProgId = 'WscMvc.RequestContext'; ClassId = '{1C36FA55-34DF-4974-94B9-D657389362B2}' } | ||||
| ) | ) | ||||
Powered by TurnKey Linux.