Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
b152944
docs: design, plan and rulings ledger for OAuth connect, Quant Forms …
stooit Nov 8, 2026
f6e58fc
docs: record the controller rulings R3-R13 and follow-up F1
stooit Nov 8, 2026
bc04c7f
docs: correct the redirect patterns of the drupal-quant client
stooit Nov 8, 2026
1647c17
docs: ledger R14 on the drupal-quant redirect patterns
stooit Nov 8, 2026
fa99dd6
feat: read the Quant write token through a credentials service
stooit Nov 8, 2026
c5fdcb7
feat: Log in to Quant with OAuth and choose the project
stooit Nov 8, 2026
7c2edb2
sec: the Quant API client sends nothing when no project token is set
stooit Nov 8, 2026
7162bff
fix: a copied site publishes nothing, the login keeps its platform an…
stooit Nov 8, 2026
f7fa948
fix: a copied site says so and can be confirmed; a 401 re-reads the w…
stooit Nov 8, 2026
0ee3376
feat: settings.php overrides for the content API endpoint and the ser…
stooit Nov 8, 2026
ad3eb5f
fix(quant_api): read the approved organisations from /api/oauth/user
stooit Nov 8, 2026
8ae8ecc
feat(quant): redirect an old alias to the new one when a page is renamed
stooit Nov 8, 2026
60b108a
fix(quant): add the config schema for the quant_metadata Views field
stooit Nov 8, 2026
0c993ef
fix(quant): unpublish /node/N and /taxonomy/term/N when content is de…
stooit Nov 8, 2026
ed0d605
fix(quant): remove Views UI options from the file metadata view
stooit Nov 8, 2026
7f91d3a
sec(quant_api): end the login in the dashboard on Log out of Quant
stooit Nov 8, 2026
8128c4b
fix(quant_api): tell the editor when the Quant login needs renewing
stooit Nov 8, 2026
307b925
fix(quant_api): name the button "Log in to Quant again" when the logi…
stooit Nov 8, 2026
9307c8a
fix(quant_api): an unreachable Quant endpoint no longer crashes the s…
stooit Nov 8, 2026
d70d243
style(quant_api): coding standards in the new tests
stooit Nov 8, 2026
6c80d8c
Merge branch 'fix/content-paths' into feat/oauth-connect
stooit Nov 8, 2026
04c880c
test: run the tests with strict config schema checks again
stooit Nov 8, 2026
2325acf
Merge branch 'fix/content-paths' into feat/oauth-connect
stooit Nov 8, 2026
4f7454b
sec(quant_api): never revoke another site's login; end replaced login…
stooit Nov 8, 2026
fa54a51
fix(quant): check the translation in the alias language before redire…
stooit Nov 8, 2026
cff9ff4
fix(quant_api): a logged-in site shows the platform of its login
stooit Nov 9, 2026
21ea4fb
feat(quant_api): show editors the pushes that did not reach Quant
stooit Nov 9, 2026
bbd05a6
style(quant_api): document the push failure store; test name in lower…
stooit Nov 9, 2026
75012b8
fix(quant_api): push failure warning survives page caching; review fixes
stooit Nov 9, 2026
f3af752
Merge branch 'fix/content-paths' into feat/oauth-connect
stooit Nov 9, 2026
3ce31ec
fix(quant): store the on/off settings as booleans; run functional tes…
stooit Nov 9, 2026
cc6d404
style(quant_api): comment line within 80 characters
stooit Nov 9, 2026
9cfd18b
fix: every shipped config file matches its schema; CI installs Webform
stooit Nov 9, 2026
22ab2f6
Merge branch 'fix/content-paths' into feat/oauth-connect
stooit Nov 9, 2026
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
22 changes: 18 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ jobs:

# token is required by quant_search. purge is required by quant_purger,
# whose kernel tests install it, so the suite cannot run without it.
# webform is required by quant_webform and its tests.
- name: Install module dependencies
run: composer --no-interaction --no-progress require drupal/token drupal/purge
run: composer --no-interaction --no-progress require drupal/token drupal/purge drupal/webform
working-directory: /var/www/drupal

# The image ships phpunit globally, but its bootstrap needs the test
Expand All @@ -65,12 +66,25 @@ jobs:
run: mkdir -p /var/www/drupal/web/sites/simpletest/browser_output
working-directory: /var/www/drupal

# Runs every unit and kernel test in the module and its submodules.
# Previously this job installed the module and stopped, so a broken
# test file went unnoticed.
# Functional tests drive the site over HTTP. Several workers, because a
# test page can call back into the same site (the fake dashboard).
- name: Start the web server
run: |
PHP_CLI_SERVER_WORKERS=4 nohup php -S 127.0.0.1:8080 .ht.router.php > /tmp/php-server.log 2>&1 &
for i in $(seq 1 30); do curl -s -o /dev/null http://127.0.0.1:8080/core/install.php && exit 0; sleep 1; done
cat /tmp/php-server.log; exit 1
working-directory: /var/www/drupal/web

# Runs every unit, kernel and functional test in the module and its
# submodules.
- name: Run tests
env:
SIMPLETEST_DB: mysql://root:drupal@mariadb:3306/drupal11
SIMPLETEST_BASE_URL: http://127.0.0.1:8080
BROWSERTEST_OUTPUT_DIRECTORY: /var/www/drupal/web/sites/simpletest/browser_output
run: vendor/bin/phpunit -c web/core --display-deprecations web/modules/custom/quant
working-directory: /var/www/drupal

- name: Web server log
if: failure()
run: tail -n 100 /tmp/php-server.log
16 changes: 8 additions & 8 deletions config/install/quant.settings.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
quant_enabled: 1
quant_enabled_nodes: 1
quant_enabled_taxonomy: 1
quant_enabled_views: 1
quant_enabled_redirects: 1
quant_enabled: true
quant_enabled_nodes: true
quant_enabled_taxonomy: true
quant_enabled_views: true
quant_enabled_redirects: true
quant_routes_export:
ssl_cert_verify: 1
disable_content_drafts: 1
quant_show_page_info_block: 1
ssl_cert_verify: true
disable_content_drafts: true
quant_show_page_info_block: true
xpath_selectors: "//li[contains(@class,\"pager__item--next\")]/a[contains(@href,\"page=\")]\r\n//li[contains(@class,\"pager__item--first\")]/a[starts-with(@href, \"/\")]\r\n//li[contains(@class,\"page-item\")]/a[contains(@href,\"page=\")]"
4 changes: 2 additions & 2 deletions config/install/quant.token_settings.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
timeout: '+1 minute'
disable: 0
disable: false
secret: null
strict: 0
strict: false
66 changes: 1 addition & 65 deletions config/install/views.view.quant_metadata_file.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,62 +75,7 @@ display:
hide_alter_empty: true
click_sort_column: value
type: file_link
settings:
relationship: none
fieldsets:
- more
- admin_label
custom_label: 0
label: ''
element_label_colon: 1
exclude: 0
element_type_enable: 0
element_type: ''
element_class_enable: 0
element_class: ''
element_label_type_enable: 0
element_label_type: ''
element_label_class_enable: 0
element_label_class: ''
element_wrapper_type_enable: 0
element_wrapper_type: ''
element_wrapper_class_enable: 0
element_wrapper_class: ''
element_default_classes: 1
alter:
alter_text: 0
text: ''
make_link: 0
path: ''
absolute: 0
replace_spaces: 0
external: 0
path_case: none
link_class: ''
alt: ''
rel: ''
prefix: ''
suffix: ''
target: ''
trim: 0
max_length: '0'
word_boundary: 0
ellipsis: 0
more_link: 0
more_link_text: ''
more_link_path: ''
html: 0
strip_tags: 0
preserve_tags: ''
trim_whitespace: 0
nl2br: 0
empty: ''
empty_zero: 0
hide_empty: 0
hide_alter_empty: 1
click_sort_column: value
type: file_link
field_api_classes: 0
settings: { }
group_column: value
group_columns: { }
group_rows: true
Expand Down Expand Up @@ -396,15 +341,6 @@ display:
published: published
revision_count: revision_count
url: url
byte_length: 0
highest_revision_number: 0
md5: 0
published_revision: 0
redirect_http_code: 0
redirect_url: 0
revision_number: 0
seq_num: 0
type: 0
pager:
type: mini
options:
Expand Down
10 changes: 0 additions & 10 deletions config/install/views.view.quant_metadata_node.yml
Original file line number Diff line number Diff line change
Expand Up @@ -274,16 +274,6 @@ display:
date_timestamp: date_timestamp
published: published
url: url
byte_length: 0
highest_revision_number: 0
md5: 0
published_revision: 0
redirect_http_code: 0
redirect_url: 0
revision_count: 0
revision_number: 0
seq_num: 0
type: 0
pager:
type: full
options:
Expand Down
10 changes: 0 additions & 10 deletions config/install/views.view.quant_metadata_taxonomy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -408,16 +408,6 @@ display:
date_timestamp: date_timestamp
published: published
url: url
byte_length: 0
highest_revision_number: 0
md5: 0
published_revision: 0
redirect_http_code: 0
redirect_url: 0
revision_count: 0
revision_number: 0
seq_num: 0
type: 0
pager:
type: mini
options:
Expand Down
8 changes: 0 additions & 8 deletions config/optional/views.view.quant_metadata_redirect.yml
Original file line number Diff line number Diff line change
Expand Up @@ -412,14 +412,6 @@ display:
redirect_http_code: redirect_http_code
redirect_url: redirect_url
url: url
byte_length: 0
highest_revision_number: 0
md5: 0
published_revision: 0
revision_count: 0
revision_number: 0
seq_num: 0
type: 0
pager:
type: mini
options:
Expand Down
11 changes: 11 additions & 0 deletions config/schema/quant.schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,14 @@ quant.token_settings:
strict:
type: boolean
label: 'Reject requests whose token does not match the route'

views.field.quant_metadata:
type: views_field
label: 'Quant metadata'
mapping:
quant_metadata:
type: sequence
label: 'Metadata to show'
sequence:
type: string
label: 'Metadata key'
152 changes: 152 additions & 0 deletions docs/2026-11-08-drupal-to-secured-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
# Drupal site to secured on Quant: design

Date: 2026-11-08. Scope: module `quant` 2.x (Drupal 11) and the matching portal changes.
Base: `origin/2.x` at e7a1b95 (contains 2.2.0 Typesense search). 1.x is out of scope.

## 1. OAuth connect ("Log in to Quant")

### Flow

1. The editor opens Quant > API and selects the platform (QuantCDN or QuantGov). The
platform sets the API endpoint (`api.quantcdn.io` / `api.quantgov.cloud`) and the dashboard
(`dashboard.quantcdn.io` / `dash.quantgov.cloud`). A `$settings` override stays possible.
2. The editor clicks "Log in to Quant". The module sends the browser to
`{dashboard}/oauth/authorize` with `client_id=drupal-quant`, `scope=full_access`, `state`,
and an S256 PKCE challenge. The state and the verifier are in the Drupal session and are
bound to the user id.
3. The editor approves in the dashboard (2FA, organisation choice: existing consent screen).
4. The callback (`/admin/config/development/quant/api/oauth/callback`) checks the state and
exchanges the code at `{dashboard}/oauth/token`.
5. The module calls `GET /api/oauth/user` and lists the projects of each granted organisation
(`GET /api/v2/organizations/{org}/projects`). The editor picks one project.
6. The module limits the OAuth token to that project (`POST /api/oauth/token/project`), then
reads the project write token (`GET .../projects/{project}?with_token=1`).
7. The module stores the connection. All existing v1 content calls continue to use the
`Quant-Customer` / `Quant-Project` / `Quant-Token` headers. The v1 content API does not
accept the OAuth bearer, so this matches the WordPress plugin 2.0.x.

To pick another project, the editor disconnects and logs in again. A limited token cannot
widen (the portal returns 409), and this is the intended security property.

### Storage

- Tokens are never in exportable config. The OAuth access token, refresh token, expiry, and the
project write token from step 6 go into the KeyValue collection `quant_api.oauth`.
- `quant_api.settings` keeps `auth_method` (`token` | `oauth`), the organisation and project
machine names, the platform and the endpoint. Add a config schema for `quant_api.settings`
(there is none today).
- A new service `quant_api.credentials` resolves the credentials in this order: `$settings`
override, OAuth store (if `auth_method=oauth`), config `api_token`. `QuantClient`,
`quant_search` and `SeedForm` read the token through this service, not from config.
- A site fingerprint (as in `ai_provider_quant_cloud`) blocks refresh on a copied database.

### Refresh

Port `AuthService` from `ai_provider_quant_cloud`: a lock for one refresh in flight, the 30 s /
300 s rotation-recovery rules of the portal, `invalid_grant` recorded as "reconnect needed",
cron refresh at half the lifetime, and a 60 s margin on use. The write token does not expire,
so a failed refresh never stops publishing. It only stops project listing and token re-sync.
The settings page shows the connection state and a "Reconnect" button.

### Fallback and limits

- The token form stays as "Connect with a project token" (CI, existing sites, settings.php).
- Multi-domain sites (`domain_config` overrides of `quant_api.settings`) stay on token
connect in this release. The OAuth form shows a notice when overrides exist.
- Disconnect deletes the local tokens. The portal has no revoke endpoint (open question).
- Drush: `drush quant:auth-status` prints the state with no token values.

### Portal change (security-sensitive, review before use)

New client `drupal-quant` in `src/config/oauth.php`, modelled on `wordpress-quant` and
`scriptattest`: public, trusted, `require_pkce`, `allow_project_limit`, scoped tokens with
`projects:read`, 90d / 365d, `scoped_token_role_checked_at_use`. Redirect URIs:
`http(s)://*/admin/config/development/quant/api/oauth/callback` (the `*/` wildcard allows a
port). A Drupal site in a subdirectory does not match and uses token connect.
Tests: a client-flip feature test, the consent Authz test (PKCE refused without a challenge),
and the project-limit test (a limited token can read only its own project write token).

## 2. Quant Forms auto-registration

### How Quant Forms works (facts)

Quant Forms is a per-page switch, not a form object. A published route gets `form_enabled`
and `form_config`. The CDN takes a POST to that URL, stores the submission, sends the
notifications and serves the page again with a message inside `id="quant-form-result"`.
`POST /v1/form` sets the switch, and a content push (`POST /v1`) accepts the same two keys
inline. A later push without the keys keeps the published values.

### Design

- `quant_webform` adds `form_enabled` and `form_config` inline to the content push of every
page that renders a Webform (the Webform canonical page and nodes or blocks that embed one).
The inline push removes the "content must exist first" ordering problem.
- On Webform save, the module queues a re-push of the pages that show that Webform.
- "Register all" (a form action and `drush quant:webform-register`) queues every such page.
- Disable or delete of a Webform sends `form_enabled: false` (no delete endpoint exists).
- The module adds the `quant-form-result` container to each rendered Webform if the form does
not already have the Quant result element.
- The mapper (`WebformQuantConfigMapper`, unit-tested) builds `form_config`:
- `mandatory_fields`: required elements that have no conditional `#states`.
- `remove_fields`: `form_build_id`, `form_token`, `form_id`, `op`, and the antibot / honeypot
timestamp fields.
- `honeypot_fields`: the Honeypot module field, if that module protects the form.
- `success_message`: the Webform confirmation message (type "message" or "inline").
- `notifications.email`: from the first enabled Webform email handler (to, cc, from,
subject). Webform tokens are converted to Quant `{{field}}` tokens where a 1:1 mapping
exists. Other tokens are removed, and the admin UI lists them.
- `notifications.slack`: a per-site webhook from the module settings, kept in KeyValue,
never in config.
- `spam_threshold` / `spam_detect_action`: module settings (default 0, off).
- `recaptcha_secret_key`: from the `recaptcha` module config, if that module protects the form.

### Support matrix

Supported: single-page forms; text, email, textarea, number, tel, url, date, select, radios,
checkboxes, hidden, likert; composite elements (address, name, contact) as flat keys such as
`address[city]`; file and managed_file elements (multipart POST, files kept by Quant);
client-side conditions (`#states`) because the static page keeps the Drupal JavaScript;
email and Slack notifications; honeypot; reCAPTCHA v2.

Partial: conditionally required fields (enforced in the browser only); email format, min, max
and pattern checks (browser only); confirmation "inline" (shown as the Quant message).

Not supported (the module shows a warning on the Webform and does not register the form):
multi-page wizards; AJAX submit; save draft; submission limits; confirmation types "page",
"url" and "modal"; handlers other than email (remote post, scripts); computed and server-side
elements; Turnstile and hCaptcha; per-submission tokens in messages. These need a server.

## 3. Quant search page from Drupal

Facts: the portal has no API to create dashboard search pages. `quant_search` already has a
Drupal search page entity. Drupal renders the page and pushes it to Quant. The page works on
Algolia and on Typesense (QuantGov).

Design (proposed reading of "creates the Quant search page"):
- A guided "Create a search page" form in `quant_search`: pick content types; the module
proposes facets from the vocabularies attached to those types plus content type and
language; default route `search`; enabled; publishes at once; optional "place the
autocomplete block". It saves a `quant_search_page` entity and registers the facets.
- Fix the entity schema (only `id` and `label` are declared), the dead
`search_page_repository` service, the `core/jquery.once` dependency, and the docs links.
- No new portal API, so no new security surface.

Portal UX pass on the dashboard search page wizard (separate branch):
- One screen with optional sections; default URL `/search`, slash normalised.
- Facet picker from `attributesForFaceting` instead of free text; filter help per backend.
- Fix the bug: the wizard saves design options to `search_options`, but the render reads
`template_options`, so step 3 has no effect today.
- Add delete (with `authorize('edit_projects')`) and a link to the live page.
- Save requires `edit_projects` (the edit route allows `read_projects` today).

## 4. Stretch: Views to search page

Read a View's base table, bundle filter, exposed filters, sorts and pager. Propose a search page
draft (content types, facets from exposed taxonomy filters, results per page). The editor
reviews the draft in the form from item 3. Only after items 1 to 3.

## 5. Docs and skills

Update `quant-cms-integrations` (OAuth connect, forms, search page) and the Drupal search
docs page (Typesense on QuantGov, facet keys `{vocab}_{langcode}`). Also document
`POST /v1/form` in `content-api.json`. Done last, against the shipped behaviour.
Loading
Loading