Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 87 additions & 1 deletion Docs/04-advanced-features/web-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1230,7 +1230,93 @@ end check
- **Bounded request body (chunked-safe):** The request-body limit (`web_server_max_body_size`) is enforced *while the body streams in*, so a chunked upload with no `Content-Length` is bounded too — an oversized body is refused with `413 Payload Too Large` without being fully buffered.
- **Global in-flight cap + request deadline:** The accepted-request cap is shared across every `listen` server via one budget, and one deadline (`web_server_response_timeout_seconds`, default 300s) is set at admission and covers the whole accepted-request lifetime. A body that is not fully received in time is shed with `408 Request Timeout` (so a slow "trickle" upload under the size cap cannot pin a slot), and a handler that does not answer in time is shed with `504 Gateway Timeout`. A shed or abandoned request is skipped and its bookkeeping pruned rather than run as zombie work.
- **No middleware system** (yet) - Implement manually
- **No built-in session management** - Implement yourself
- **No automatic CSRF rejection** - `enable csrf protection` records the flag; your handler still compares `header "X-CSRF-Token"` to `get session value "csrf_token"`. Automatic rejection would change every handler without a test that requires it.

## User sessions

`listen … with sessions enabled` starts a server with a built-in session store.
`wait for request` does **not** create a session automatically — you call
`create session` / `get session` explicitly. `respond … and set session` appends
a `Set-Cookie` header; `respond … and clear session` expires it.

```wfl
listen on port 8080 as web_server with sessions enabled
configure sessions on web_server with timeout 1800000 and storage "memory"
enable csrf protection on web_server

main loop:
wait for request comes in on web_server as req
store sess as get session from req
check if sess is nothing:
store sess as create session for req
set session value "user_id" to "guest" in sess
store csrf as generate csrf token for sess
respond to req with "ok" and set session sess
otherwise:
store user_id as get session value "user_id" from sess
respond to req with user_id
end check
end loop
```

Session objects expose `id` (and `created_at` / `last_activity`) through
`id of sess`. User data is **not** dumped onto that object — use
`get session value` / `set session value` so a key named `id` cannot collide
with the session identifier.

### Storage backends

`configure sessions … and storage` picks the backend (`memory`, `file`, or
`database`). `database` is SQLite via the in-tree sqlx dependency, not
Postgres or MySQL. Defaults come from `.wflcfg` (`session_storage`,
`session_timeout_ms`, cookie flags); statement-level `configure` / `enable`
override them for that server. See [Configuration Reference](../reference/configuration-reference.md#sessions).

- **memory** — process-local map. Default, test-safe.
- **file** — one JSON file (`session_file_path`), written with a temp file + rename.
- **database** — SQLite file (`session_db_path`) with `wfl_sessions` and `wfl_session_kv`.

Values must be JSON-safe (text, number, bool, list, object, nothing). Functions,
natives, and binaries produce an actionable error. `create session` fails cleanly
when the store is full (`session_max_sessions`).

Concurrent handlers may touch the same store: last write wins per session id.
Two handlers on **different** sessions do not block each other at the language
level; they still interleave on one thread under `main loop concurrently:`
(cooperative concurrency, not parallel cores).

### Cookies and CSRF

The default cookie name is `wfl_sid`, with `Path=/`, `HttpOnly`, `SameSite=Lax`,
and `Max-Age` from the timeout. `enable secure cookies` (or
`session_cookie_secure = true`) adds `Secure`. `respond … and set session`
keeps any `and headers` you already set.

`generate csrf token for session` returns a new hex token and stores it on that
session as `csrf_token`. Check it yourself:

```wfl
store provided as header "X-CSRF-Token" of req
store expected as get session value "csrf_token" from sess
check if provided is equal to expected:
respond to req with "ok"
otherwise:
respond to req with "CSRF token invalid" and status 403
end check
```

### Expiry, statistics, and raw storage

`find expired sessions on web_server` returns the expired records so you can
`destroy` them. `get session statistics from web_server` is a map with
`active_sessions`, `total_created`, `expired_count`, and `storage_type`.
Destroyed or missing cookies make `get session` return `nothing`; `set` after
destroy is an error.

The storage KV API is a separate key/value map on the same backend
(`store session_data to storage …`, `load session data from storage …`,
`delete session data from storage …`) — useful for one-off blobs that are not
tied to a cookie.

All of these ceilings, together with the request timeout and body-size limits, are part of one shared [execution budget](../reference/configuration-reference.md#execution-budget-resource-limits).

Expand Down
114 changes: 113 additions & 1 deletion Docs/reference/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,23 @@ All keys currently loaded from config files, with defaults.
| `web_socket_max_message_size` | integer ≥ 1 | `1048576` (1 MiB) | Max size of a single WebSocket text message (bytes); larger frames are dropped |
| `web_socket_max_queued_bytes` | integer ≥ 1 | `16777216` (16 MiB) | Global ceiling on queued WebSocket payload bytes across all connections |

### Sessions

| Key | Type | Default | Purpose |
|---|---|---|---|
| `session_timeout_ms` | integer | `1800000` | Idle timeout in milliseconds (30 minutes) |
| `session_storage` | `memory` / `file` / `database` | `memory` | Backend for `listen … with sessions enabled` |
| `session_db_path` | string | `wfl_sessions.db` | SQLite file when storage is `database` |
| `session_file_path` | string | `wfl_sessions.json` | JSON file when storage is `file` |
| `session_cookie_name` | string | `wfl_sid` | Session cookie name |
| `session_cookie_secure` | bool | `false` | Add `Secure` to `Set-Cookie` |
| `session_cookie_samesite` | `Lax` / `Strict` / `None` | `Lax` | `SameSite` attribute |
| `session_cookie_httponly` | bool | `true` | Add `HttpOnly` to `Set-Cookie` |
| `session_csrf_enabled` | bool | `false` | Default for `enable csrf protection` |
| `session_max_sessions` | integer ≥ 1 | `10000` | DoS ceiling; `create session` fails when full |

No session secret belongs in `.wflcfg` (config files must not hold app secrets). Session IDs and CSRF tokens come from the OS CSPRNG.

### Execution budget keys (summary)

A single [`ExecutionBudget`](#execution-budget-resource-limits) governs every
Expand Down Expand Up @@ -613,6 +630,100 @@ Global ceiling in bytes on all WebSocket payloads queued across every connection
- **Default:** `16777216` (16 MiB)
- **Example:** `web_socket_max_queued_bytes = 8388608`

### Sessions

Defaults for `listen … with sessions enabled`. Statement-level `configure sessions`
and `enable csrf protection` / `enable secure cookies` override these for that
server. Timeouts are **milliseconds** so they match `configure sessions … with
timeout 1800000`. See [User sessions](../04-advanced-features/web-servers.md#user-sessions).

#### `session_timeout_ms`

Idle lifetime of a session in milliseconds. `get session` of an expired id
returns `nothing`. Cookie `Max-Age` is this value in seconds (`timeout_ms / 1000`).

- **Type:** Integer (milliseconds)
- **Default:** `1800000` (30 minutes)
- **Example:** `session_timeout_ms = 600000` # 10 minutes

#### `session_storage`

Backend used when the program does not `configure sessions … and storage`.
`memory` is test-safe (nothing written to disk). `file` is one JSON file.
`database` is SQLite (not Postgres or MySQL).

- **Type:** `memory` / `file` / `database`
- **Default:** `memory`
- **Example:** `session_storage = database`

#### `session_db_path`

SQLite file created when `session_storage = database`.

- **Type:** File path string
- **Default:** `wfl_sessions.db`
- **Example:** `session_db_path = /var/lib/wfl/sessions.db`

#### `session_file_path`

JSON file used when `session_storage = file`. Writes are atomic (temp file + rename).

- **Type:** File path string
- **Default:** `wfl_sessions.json`
- **Example:** `session_file_path = /var/lib/wfl/sessions.json`

#### `session_cookie_name`

Name of the session cookie (`Set-Cookie` / `Cookie`).

- **Type:** String
- **Default:** `wfl_sid`
- **Example:** `session_cookie_name = sid`

#### `session_cookie_secure`

When `true`, `Set-Cookie` includes `Secure`. Also set by `enable secure cookies`.
Leave `false` for plain HTTP (the cookie will not come back on HTTP if `Secure`
is set).

- **Type:** Boolean
- **Default:** `false`
- **Example:** `session_cookie_secure = true`

#### `session_cookie_samesite`

`SameSite` attribute on the session cookie.

- **Type:** `Lax` / `Strict` / `None`
- **Default:** `Lax`
- **Example:** `session_cookie_samesite = Strict`

#### `session_cookie_httponly`

When `true`, `Set-Cookie` includes `HttpOnly` so JavaScript cannot read the id.

- **Type:** Boolean
- **Default:** `true`
- **Example:** `session_cookie_httponly = true`

#### `session_csrf_enabled`

Default CSRF flag when the program omits `enable csrf protection`. v1 records
the flag only; handlers still compare tokens themselves.

- **Type:** Boolean
- **Default:** `false`
- **Example:** `session_csrf_enabled = true`

#### `session_max_sessions`

Maximum stored sessions. `create session` fails with an actionable error when
the store is full.

- **Type:** Integer (at least 1)
- **Default:** `10000`
- **Example:** `session_max_sessions = 1000`

### Execution budget (resource limits)

WFL enforces every resource ceiling through a single shared **execution budget**
Expand Down Expand Up @@ -738,8 +849,9 @@ Application settings (business ports, feature flags) belong in data files your p
| HTTPS defaults without hardcoding paths | `web_server_tls_cert_file` / `web_server_tls_key_file` |
| Large uploads | `web_server_max_body_size` |
| Bound request backlog under load | `web_server_request_queue_bound` |
| Session timeout / store / cookie | `session_timeout_ms`, `session_storage`, `session_cookie_*` |

TLS intent always lives in the program (`secured`); config only supplies default file paths.
TLS intent always lives in the program (`secured`); config only supplies default file paths. Session `configure` / `enable` statements override `.wflcfg` for that server.

### Shell / subprocesses

Expand Down
4 changes: 2 additions & 2 deletions Docs/reference/keyword-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,8 +369,8 @@ connect to database at "sqlite://app.db" as db
- 24 contextual keywords CAN be used as variables in certain contexts
- 5 appear contextual but are actually always reserved

### "What about `secured`, `certificate`, `key`, `redirecting`, `content_type`, `transaction`?" → Not keywords
These words are recognized purely by position — inside `listen` / `respond` statements, or in `in transaction on db:` / `end transaction` — and are **never reserved**. Use them as variable names freely. See [Marker Words That Are Not Keywords](reserved-keywords.md#marker-words-that-are-not-keywords-at-all).
### "What about `secured`, `certificate`, `key`, `redirecting`, `content_type`, `transaction`, `session`?" → Not keywords
These words are recognized purely by position — inside `listen` / `respond` statements, session phrases, or in `in transaction on db:` / `end transaction` — and are **never reserved**. Use them as variable names freely. See [Marker Words That Are Not Keywords](reserved-keywords.md#marker-words-that-are-not-keywords-at-all).

---

Expand Down
4 changes: 4 additions & 0 deletions Docs/reference/reserved-keywords.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,17 @@ A few words have special meaning in exactly one statement position but are **not
- `redirecting` - redirect marker in `listen on port 8080 redirecting to port 8443 as server`
- `content_type` - response content type marker in `respond to req with ... and content_type "text/html"`
- `transaction` - transaction block marker in `in transaction on db:` and `end transaction`
- `sessions` / `session` - session markers in `listen … with sessions enabled`, `configure sessions`, `create session`, `get session`, `set session value`, `destroy session`, `respond … and set session` / `and clear session`
- `storage` - session storage marker in `configure sessions … and storage "memory"` and `store session_data to storage …`
- `csrf` / `protection` - CSRF markers in `enable csrf protection` and `generate csrf token for …`

```wfl
// All perfectly valid — these words are not reserved:
store key as "secret_key_456"
store certificate as "diploma"
store secured as yes
store transaction as "TX-1094"
store session as "active"
```

`transaction` is recognized in exactly two positions: directly after a leading
Expand Down
67 changes: 67 additions & 0 deletions History/dev-diary/2026/2026-09-03-sqlite-user-sessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Dev Diary — 2026-09-03 — SQLite-backed user sessions (issue #555)

## Summary

WFL programs can now keep HTTP sessions with the natural-language surface
from issue #555. `listen … with sessions enabled` builds a runtime
`SessionManager`; `configure` / `enable` override `.wflcfg` defaults; handlers
create, read, update, and destroy sessions and attach `Set-Cookie` on
`respond`. Three stores ship: in-memory, a JSON file, and SQLite (`database`).

```wfl
listen on port 8080 as web_server with sessions enabled
configure sessions on web_server with timeout 1800000 and storage "database"
enable csrf protection on web_server

main loop:
wait for request comes in on web_server as req
store sess as get session from req
check if sess is nothing:
store sess as create session for req
set session value "user_id" to "guest" in sess
respond to req with "ok" and set session sess
otherwise:
store user_id as get session value "user_id" from sess
respond to req with user_id
end check
end loop
```

## Design decisions

- **Language statements, not a stdlib native.** Natives are
`fn(Vec<Value>) -> Result<Value, RuntimeError>` with no interpreter, request,
or sqlx access — they cannot persist sessions or set cookies.
- **No new lexer keywords.** `session` / `sessions` are positional markers, so
existing `store session as "active"` programs keep working. Keyword count
stays 181.
- **User data is not dumped onto the session object.** `id of sess` uses the
existing `property of object` form; values live behind
`get` / `set session value` so they cannot collide with `id`.
- **Timeouts are milliseconds.** `configure sessions … with timeout 1800000`
is 30 minutes; `session_timeout_ms` matches that unit.
- **CSRF is explicit.** `generate csrf token for session` stores a hex token
on that session. `enable csrf protection` records the flag. v1 does not
auto-reject requests — that would change handler semantics without a test
that requires it. The #555 e2e program checks `X-CSRF-Token` in user code.
- **No session secret in `.wflcfg`.** IDs and tokens come from the OS CSPRNG
(same source as `secure_random_bytes`).

## Storage

SQLite uses the in-tree sqlx dependency (`sqlite://` + `create_if_missing`)
with `wfl_sessions` and `wfl_session_kv`. File storage writes a temp file and
renames. Memory and file share one `tokio::sync::Mutex`; the database backend
uses the sqlx pool. Last write wins per session id. `create session` fails
cleanly when `session_max_sessions` is reached.

## Testing

Red commit `5a1a49e` added parser and store tests that failed for the intended
reasons (unknown AST variants / missing `wfl::interpreter::sessions`). Green
commits implement the surface. `scripts/run_web_tests.sh` drives
`TestPrograms/web_server_session_test.wfl` with `curl -c/-b` (login cookie,
profile, CSRF, logout clear, stats, storage KV).

Issue #555 stays open: WebSockets already shipped; keyword-reference web
examples are a separate leftover.
17 changes: 17 additions & 0 deletions TestPrograms/docs_examples/_meta/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,23 @@
],
"description": "Concurrent request handling with main loop concurrently."
},
"docs_examples/web_servers/session_login.wfl": {
"doc_section": "Docs/04-advanced-features/web-servers.md#user-sessions",
"type": "snippet",
"validate_layers": [
1,
2,
3,
4
],
"skip_execution": true,
"tags": [
"web-server",
"sessions",
"cookies"
],
"description": "Session-enabled listen, create/get session, set value, CSRF token, and Set-Cookie on respond."
},
"docs_examples/containers/basic_container_01.wfl": {
"doc_section": "Docs/04-advanced-features/containers-oop.md#basic-container",
"type": "executable",
Expand Down
23 changes: 23 additions & 0 deletions TestPrograms/docs_examples/web_servers/session_login.wfl
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// CI-SKIP: starts a session-enabled server; needs an HTTP client (layers 1-4)
// Built-in user sessions: create, store values, set the session cookie.
//
// Validated for syntax/analysis/lint only (layers 1-4): running it needs a
// live client, so execution is skipped.

listen on port 8080 as web_server with sessions enabled
configure sessions on web_server with timeout 1800000 and storage "memory"
enable csrf protection on web_server

main loop:
wait for request comes in on web_server as req
store sess as get session from req
check if sess is nothing:
store new_sess as create session for req
set session value "user_id" to "guest" in new_sess
store csrf as generate csrf token for new_sess
respond to req with csrf and set session new_sess
otherwise:
store user_id as get session value "user_id" from sess
respond to req with user_id
end check
end loop
Loading
Loading