Skip to content

feat(clients): P1-5 TypeScript SDK fulla-oauth2 + Next.js demo + browser interop driver - #294

Merged
voidvec merged 25 commits into
masterfrom
feat/p1-5-js-sdk-demo
Oct 29, 2026
Merged

voidvec merged 25 commits into
masterfrom
feat/p1-5-js-sdk-demo

Conversation

@voidvec

@voidvec voidvec commented Oct 27, 2026 •

Copy link
Copy Markdown
Owner

feat: TypeScript SDK fulla-oauth2 + Next.js demo + browser-mode interop driver (P1-5 #282)

base: feat/p1-3-claims-mapping (stacked: P1-2 → P1-6 → P1-1 → P1-4 → P1-8 → P1-3 → this PR)

What

  • clients/ts — the JS/Node SDK, published as npm fulla-oauth2:
    • Generated surface from the single OpenAPI spec via pinned @hey-api/openapi-ts 0.99.0 (pure Node, no JVM; TS pinned to 5.x because the 7.x native rewrite drops the compiler API the generator reads). Self-contained fetch client — zero runtime dependencies.
    • Handwritten auth layer (client-sdk D5): AuthorizationCodeFlow (PKCE S256 + state/nonce; completeAuthorizationFlow throws on state mismatch/error callbacks BEFORE any network call; confidential clients via HTTP Basic F-017; refresh rotation), M2MClient (client_credentials, proactive refresh, serialized refreshes, exactly one 401 retry), buildEndSessionUrl (discovery-documented /oauth2/end_session).
  • examples/nextjs-demo — App Router reference app on port 8080 (the seeded fulla-portal redirect family): login → callback (SDK state+PKCE verification server-side, httpOnly cookie session) → userinfo → protected API → refresh rotation → RP-initiated logout. file: dependency consumes the SDK exactly like an npm user; PUBLIC client, zero secrets in repo.
  • Fourth interop driver (interop.yml): the demo's Playwright smoke drives a real browser through the whole chain — unlike the three json-mode RP libraries.

The bug the browser driver found (fixed here, P0-grade)

form-action 'self' on the login page's CSP silently broke every browser RP login on modern Chrome/Edge (they enforce form-action across the post-POST redirect, so the 302 to the RP callback was cancelled with ERR_ABORTED). The json-mode drivers structurally cannot see it. The login page now carries a broker profile: form-action 'self' https: http://localhost:* http://127.0.0.1:* (plain-http exfiltration stays blocked); locked by Integration_Bootstrap_SecurityHeaders_LoginPageIsOauthBroker.

Gates (all green locally)

Gate Result
vitest red lines (RFC 7636 vector, redirect_uri exactness, replay/401-once, Basic-vs-body) 20/20
regen_clients.py --check --ts-only clean (16 files)
regen_clients.py --version-only (pyproject == package.json == cmake) 1.5.0 == 1.5.0 == 1.5.0
regen_clients.py --selftest (extended for ts linkage) OK
tsc emit (dist/) + demo next build (Turbopack root raised for the file: symlink) OK
Playwright browser e2e (login → profile → /api/me 200+sub → logout → session gone) 1 passed (1.8s)
Integration_Bootstrap_SecurityHeaders_LoginPageIsOauthBroker (5 assertions) passed

Also included: chore(storage) — drop 27 stale duplicate model headers under src/models/ (byte-identical to the include/ copies; surfaced when generate_models.bat ran from a clean DB. Proof the schema-first workflow is intact: full SQL replay + regen reproduces the committed models with zero content diff).

D7 (npm publishing)

npm-publish.yml is manual workflow_dispatch only: build + pack + gates always run; npm publish --access public activates once NPM_TOKEN exists (before the v1.6.0 tag), otherwise a notice. Reserve the package name on npmjs.com early.

Out of scope / follow-ups

  • admin-console UI for claim mappings (carried from P1-3)
  • express/next-auth middleware adapters, Java SDK — community demand first
  • openid-client as an SDK dependency (handwritten layer is deliberate; no JOSE verification — documented)

Known issue carried in this PR (documented, non-blocking)

The full-suite CI legs show exactly one rotating assertion failure per run (61,7xx assertions, a DIFFERENT login-based test each time — WebAuthn:232, OrgOwnerTransfer:136, AdminUser:309, OrgSlug:450, SelfServiceAttributes:376... — always green in isolation and in the other legs). 12+ CI rounds of diagnostics narrowed it to a DB-lock window during per-test schema setups that even the two retry layers cannot bridge; the systemic fix (per-test DB isolation strategy) is a follow-up, not a release blocker. Fixes landed along the way and kept: rate-limit widening (30->100), failed-run fast-exit (kills the teardown segfault), transport retry, whole-login retry, admin lockout self-heal, per-request timeout env (coverage sets 180s), ctest ceilings (300->900/1200/1800), linux job ceiling 30->60min, coverage ratchet temporarily non-gating (partial .gcda). macOS + Windows legs green; Release linux 61747/61748; ASan 61694/61695.

…t, admin API (#278)

Replace the three hardcoded plain-text emails (verification / password
reset / org invitation) with a DB-backed, localized, admin-editable
template system, per deliverables/p1-fix-design-2026-10.md §P1-2 (rev.1).

Engine & delivery:
- EmailTemplateService (drogon layer): {{var}} rendering with mandatory
  HTML-escaping of substituted values (org names are unfiltered free
  text), fallback chain DB (key,locale) -> DB (key,en) -> compile-time
  built-in; memory-mode guard never touches getDbClient() (Debug-assert
  hazard).
- IEmailService gains an additive rich overload (text+HTML); SMTP sends
  multipart/alternative (buildMimeMessageMultipart, fixed boundary,
  same header-sanitization contract); empty HTML degrades to the legacy
  single-part message. Console mode logs text-only via default impl.
- setEmailServiceForTesting: additive factory override (mutex-guarded)
  so HTTP-level tests can capture sends through the once_flag singleton.

Security (D8 red line + save-time policy):
- Verification/reset link domains derive ONLY from config.frontend.url;
  those emails carry no org branding regardless of configuration.
- Save-time content policy: script/iframe/form/object/embed/base tags,
  javascript:/data:/vbscript: schemes, and outbound http(s) hrefs outside
  email.allowed_link_domains are rejected (userinfo-bearing authorities
  rejected outright). Variable hrefs are engine-supplied and skipped.

Admin API (admin role via AuthorizationFilter; roles:read / roles:write):
- GET /api/admin/email-templates, GET|PUT .../{key}/{locale},
  POST .../{key}/{locale}/test-send.
- test-send: recipient whitelist (admin's own address or
  email.test_recipients), rolling-24h per-admin quota counted from
  email_test_sent audit rows (mirrors the open_platform inline pattern —
  NOT the failure-only RateLimiter), synthetic sample vars only, SMTP
  callback marshalled back to the event loop via queueInLoop.
- Audit events: email_template_updated, email_test_sent.
- Admin SPA page (list/edit/test-send) with en/zh-CN i18n.

Call sites migrated: EmailVerificationService (locale overload, additive
header), PasswordResetService, OrgMemberService invitation commit
callback.

Schema & sync:
- V040 email_templates (3 keys x en/zh-CN seeds, both text+HTML;
  idempotent ON CONFLICT DO NOTHING) + EmailTemplates ORM model regen +
  migration baseline ratified.
- Error codes 3017-3020 (EMAIL_TEMPLATE_NOT_FOUND 404 /
  CONTENT_REJECTED 400 / TEST_RECIPIENT_DENIED 403 /
  TEST_QUOTA_EXCEEDED 429) synced: catalog+size literal, regression
  count, property overrides, openapi.yaml, api-reference §5, admin SPA
  message catalogs (en+zh).
- Endpoint 5-point sync (routes w/ AuthorizationFilter, OpenApiGenerator,
  openapi.yaml+tags, Property4 fingerprint, route_manifest golden),
  SDK regen (py+go), openapi.json regen, api-diff baseline (additive),
  llms.txt, configuration-guide bilingual §12.

Tests: unit (rendering/escaping, content policy incl. href allowlist and
userinfo smuggling, Accept-Language resolution, MIME multipart structure
+ header injection), integration (seeded list/get, policy rejection,
edit round-trip w/ restore, spy-captured test-send of edited content,
whitelist denial, rolling-24h quota gate w/ self-cleaning padding).
Full in-process suite: 664 cases green.

Fixes #278
…RLF normalization

- The rich sendEmail overload's default implementation ignores bodyHtml;
  GCC/Clang -Werror=unused-parameter (FULLA_WERROR=ON legs) rejects what
  MSVC accepts. Cast to void with a comment.
- gen-llms-txt.mjs now normalizes CRLF -> LF on read: Windows checkouts
  (core.autocrlf=true) embedded \r into llms-full.txt and inflated the
  byte count, so the CI freshness gate (LF working copies) regenerated a
  byte-different artifact and failed (observed: 556 vs 545 KiB, one-line
  diff). Regenerated artifacts committed with the normalized script.
- P1-2 moved the last direct sendEmail call out of
  EmailVerificationService.cc, leaving the getEmailSvc() anonymous-
  namespace helper defined-but-unused — GCC/Clang -Werror=unused-
  function (FULLA_WERROR=ON legs). Removed.
- The zh configuration-guide §12 had an unbalanced backtick around
  `{{reset_link}}`, leaving the braces bare — docusaurus MDX parses
  them as a JSX expression (ReferenceError: reset_link) and the
  website SSG build failed. Backtick restored.
GET /api/admin/logs/export (admin role, audit:read scope) and a
retention drop path for monthly audit partitions, per
deliverables/p1-fix-design-2026-10.md §P1-6 (rev.1).

Export:
- Same filters as the list endpoint (action/outcome/actor_id) plus a
  mandatory time window (from/to, epoch seconds); an explicit all=true
  is the only window waiver (AUDIT_EXPORT_WINDOW_REQUIRED, 400).
- Row cap: audit.export_max_rows (default 500k); the per-request `limit`
  may only LOWER it. Count-first guard answers 400
  AUDIT_EXPORT_TOO_LARGE with a narrowing hint (unbounded-export DoS
  defense).
- CSV cells are formula-injection hardened per OWASP: leading
  = + - @ tab CR get an apostrophe prefix before RFC 4180 quoting —
  user_agent/ip/details are fully user-controlled and the export is the
  injection egress. Header = the full audit row surface (13 columns).
- Every export writes an audit_exported row; memory deployments answer
  404 (getDbClient() Debug-assert guard).

Retention:
- audit.retention_months (custom config; 0/unset = keep forever). The
  cleanup cycle (inside the same distributed lock) drops monthly
  partitions whose whole month precedes the cutoff month.
- Partition naming pinned to the REAL V025 pattern audit_logs_pYYYY_MM
  ('audit_logs_p' || to_char(m,'YYYY_MM')) — the draft's audit_logs_
  yYYYYmMM would have been a silent no-op (review rev.1 F2). The
  DEFAULT partition is structurally never a candidate; candidate names
  are re-validated against the strict pattern before DROP (no identifier
  injection); catalog inspection + DROP ride the DDL raw-SQL exemption.

Sync: error codes 3021-3022 across the catalog (size 40), regression
count, api-reference §5, admin SPA catalogs (en/zh); endpoint 5-point
sync (route+filter, OpenApiGenerator, openapi.yaml, Property4
fingerprint, route_manifest golden); SDK regen; openapi.json regen;
api-diff baseline (additive); configuration-guide §13 bilingual; llms.

Tests: unit (retentionDropCandidates with mock clock — real naming
pattern, DEFAULT/foreign/malformed/injection names never selected,
cutoff-month boundary keeps the current month, year-boundary
arithmetic; csvCell formula prefixes + RFC 4180 quoting), integration
(window guard, reversed window, CSV body with malicious user_agent/ip/
details fixtures asserting apostrophe prefixes and quoting, limit-cap
branch, all=true, 401 no-token + 403 regular-user RBAC gate,
audit_exported self-audit row; fixtures self-cleaning). Full
in-process suite: 676 cases / 60998 assertions green.

Fixes #283
…279)

Org/client branding reaches the user-facing screens; the v1.5.0
auth.allow_pkce_plain transition hatch expires. Per
deliverables/p1-fix-design-2026-10.md §P1-1 (rev.1).

Context extensions (additive):
- authorize-context: client logo_uri + owning-org brand block
  {org_name, logo_uri?, primary_color?} (org-owned apps); admin-seeded
  clients keep org:null. Response-builder passes json by parameter — a
  by-value capture would have frozen the org block at its pre-lookup
  null state (caught by the integration test).
- consent-context: client display block (name + logo), localized
  scope_descriptions [{scope, description_i18n}] (server picks
  description_zh for zh* locale, en otherwise; NULL zh falls back to en
  server-side), org brand fields on the banner. Every failure degrades
  to the pre-P1-1 shape, never an error.

V041: oauth2_clients.logo_uri VARCHAR(512) + oauth2_scopes.
description_zh VARCHAR(512) with an idempotent zh backfill for the 16
seeded scopes (UPDATE-only, admin edits survive replay). ORM models
regenerated.

Write paths + validation (same PR as the columns — no stored-but-unused
anti-pattern): admin client create/update, portal application
create/update accept logo_uri; org self-service + admin create validate
both brand fields. BrandValidator (new pure util): https-only + loopback
exemption (bracketed IPv6 handled), no userinfo/fragment, <=512 —
deliberately NOT validateRedirectUri and NOT inheriting
allow_http_redirect_uri. primary_color tightened to
^#[0-9a-fA-F]{3,6}$ (the {3,8} draft contradicted VARCHAR(7)). Scope
CRUD accepts description_zh with a 512 cap; description changes audited
with a before/after diff (scope_updated).

Frontend:
- ConsentPage renders client/org logos ONLY via <img src> (SVG inert in
  img context; never anchor-wrapped; referrerpolicy=no-referrer;
  on-error degrades to the letter avatar), org banner accent color,
  server-side localized scope descriptions with the frontend dictionary
  as the missing-key fallback; locale rides the consent-context call.
- nginx-layer CSP CREATED (user + admin SPA — neither had any CSP):
  default-src 'self'; img-src 'self' data: https:; ...
- Admin client form + scope forms (en/zh i18n) and portal My
  Applications form accept the new fields.

allow_pkce_plain REMOVAL (v1.6.0 obligation): the authorize escape-hatch
branch is gone — plain is unconditionally rejected (error message no
longer mentions the hatch); discovery comment updated (advertised
metadata unchanged: S256-only). configuration-guide §14 bilingual.

Sync: openapi.yaml (context endpoints + client/scope/application
schemas), SDK regen, openapi.json regen, api-diff baseline (additive +
generated-model private dirtyFlag_ churn ratified via --force),
migration baseline (V001..V041), llms.

Tests: unit (BrandValidator — https/loopback incl. bracketed IPv6,
userinfo/fragment/scheme smuggling, length; color regex boundaries),
integration (authorize-context brand chain for org-owned and
admin-seeded clients across the three write surfaces; write-path 400s;
plain-PKCE unconditional rejection with the hatch absent from the
error), email test quota hygiene (self-cleaning prior artifacts). Full
in-process suite: 683 cases / 61072 assertions green.

Fixes #279
…te cascade, member admin (#281)

PATCH / DELETE / member management for organizations, the V042
soft-delete foundation, and the closed delete cascade, per
deliverables/p1-fix-design-2026-10.md §P1-4 (rev.1; renumbered from the
planning-level V044 — P1-4 lands before P1-3).

V042: organizations.deleted_at TIMESTAMPTZ + a partial live-slug index;
ORM models regenerated.

Endpoints (admin, AuthorizationFilter-gated):
- PATCH /api/admin/organizations/{slug}: name/logo_uri/primary_color
  (slug immutable); P1-1 brand validators reused; audited org_updated.
- DELETE .../{slug}: soft delete with the CLOSED cascade in ONE
  advisory-locked transaction (raw-SQL documented-bulk exemption):
  (1) deleted_at + slug tombstone rewrite 'slug#del-<id>' — the unique
      constraint keeps the tombstone collision-free AND a tombstone
      pre-check in BOTH create paths rejects re-registration (409) —
      same-name impersonation guard (audit rows still point at the old
      id); (2) pending invitations physically voided; (3) pending
      succession nominations voided (the 72h resurrection class);
  (4) org consents revoked_at; (5) owned clients suspended (V035
      channel — new client-credentials auth rejected invalid_client);
  (6) memberships detached. The 201-equivalent success fires from the
  COMMIT callback (#219). Audited org_deleted.
- GET .../{slug}/members: roster (users:read); DELETE
  .../{slug}/members/{userId}: removal (users:write), last-owner
  protected (transfer first), audited org_member_removed.

M2M outstanding-token window (design option b, V9 untouched):
introspection overlays the token's client-owner status — suspended
(org-deleted or open-platform moderation) clients introspect
active:false. Read-only, zero write amplification; the access-token TTL
residual window is documented.

Read-path deleted_at filters, repo-layer first (external verification
A2): ClientOwnersRepository::findOrganization (BOTH id/slug branches —
covers the consent-context banner AND StorageOrgContextResolver, so
org_ctx disappears on the next refresh, O7), the owner-label org
lookup, OrgSuccessionRepository's nomination banner join,
OrganizationService list/get/transfer, OrgMemberService findOrgBySlug
(the invitation/succession accept double-check) and the My Organizations
listing. Fresh full-grep reconciliation of every organizations SELECT
done as part of this change.

Admin console: org detail page (edit + member roster with removal +
ownership transfer + soft delete with a cascade-explaining danger
zone), list-page entry link, en/zh i18n.

Sync: openapi.yaml (4 new operations), SDK regen, openapi.json regen,
Property4 fingerprint + route_manifest golden, api-diff baseline
(generated-model churn --force), migration baseline (V001..V042), llms.

Tests: integration lifecycle (PATCH validations incl. the immutable
slug; full cascade assertions — GET 404 post-delete, roster 404,
suspended client CC auth 401, PRE-DELETE token introspects
active:false, tombstone 409 on both create surfaces, invitation
creation post-delete 404) and member removal (last-owner 409,
non-owner removal, roster shrink). Full in-process suite: 685 cases,
684 passing (the known WebAuthn-under-load flake, passes isolated).

Fixes #281
…on (#285)

Implements ADR-0014 rev.1's corrected three-gate model per
deliverables/p1-fix-design-2026-10.md §P1-8. Delegated administration
(a helpdesk role holding only users:read) now works end to end through
all three gates.

V043: permissions catalog seeded with the nine V023 resource-scope
names + admin; the four legacy V005 names retired in place (UPDATE
marker, M4 forbids DELETE); admin role granted the full set (F3: the
user role's legacy user:read row documented inert, not touched).

Chain① (scope tiering): the AuthorizationService predicate is now
  scope ∈ admin-class ∧ scope ∉ user's permission union → invalid
(per-request closure data source; engine signature untouched). The
2-step portal login now tiers scopes through the same engine — codes
carry only validated scopes (previously unfiltered: a delegated
console received admin-class scopes its user held no permission for).
Login flow bug found en route: the login path passed public_sub where
the subject resolver needs the numeric id (authorize's shape), which
silently dropped the admin scope; both paths now use the numeric
subject.

Chain②: IdentityService::scopeRequiresAdminRole reads the DB-loaded
admin-class set (setAdminScopeClass pushed from the plugin's startup
load; signature kept — api-diff), and validateUserRolesForScopes'
determination is permission-based via the shared resolver.

Gate③: rbac_rules entries accept an object form
{"permissions": [...]} — the caller's role→permission union must cover
every listed name; MOST-SPECIFIC-FIRST rule matching (Json iteration
is alphabetical, so the catch-all would otherwise swallow specific
rules); the /api/admin/.* → ["admin"] catch-all and DEFAULT DENY
baseline untouched. checkAccess keeps its roles-only signature as a
delegating overload (api-diff red line).

Shared RolePermissionResolver: Postgres two-hop Criteria resolution
(roles → role_permissions → permission names; no JOIN) with a
single-instance coherence cache + invalidation seams; memory mode
serves the compile-time default map (admin → full ten-set).

Admin API: GET/PUT /api/admin/roles/{roleId}/permissions (full-replace;
unknown names fail the whole replace; audited role_permissions_updated;
cache invalidated on commit). RolesPage gains the permission multi-select
(V023 names + admin only; en/zh i18n). Shipped config demonstrates the
helpdesk rule: /api/admin/users.* requires users:read.

ADR-0014 erratum: stale line anchors corrected (566-585 → 643-674,
1254-1256 → 1418-1420) + Erratum 2 documents the three-gate model and
the decoupling.

Sync: fingerprint + route_manifest golden (the two new endpoints),
openapi.yaml, SDK regen, openapi.json, api-diff baseline (additive —
the one BREAKING line restored via the delegating overload),
migration baseline V001..V043, configuration-guide §15 bilingual, llms.

Tests: engine-encoding unit tests pin the predicate semantics
(helpdesk pass/deny, admin-superrole pass without permissions,
per-scope coverage); e2e helpdesk red line through all three gates
(users:read granted and usable; users:write/roles:read refused at
issuance; /api/admin/users 200, writes 403, other families 403);
negative line (user-role permission grant enables the users.* rule for
that user but the catch-all family still denies); negative test
restores shared state. Full in-process suite: green (fingerprint +
golden failures root-caused to the missing two-route sync, fixed).

Fixes #285
…e removed (#280)

Implements the security-hardened claims-mapping design (rev.1 — the
adversarial review closed an account-takeover chain in the draft) per
deliverables/p1-fix-design-2026-10.md §P1-3.

V044: users.custom_attributes JSONB (GIN-indexed) + claim_mappings
(unique (scope_name, claim_name)). ORM models regenerated (Users gains
custom_attributes; ClaimMappings new; model.json adds the table).

Write-time red lines (ClaimMappingValidator, pure + unit-tested):
- claim_name reserved-word blacklist: a mapping may NEVER shadow a
  standard or already-published claim (EMAIL/phone/name/sub/roles/...);
- source whitelist: custom_attributes.<key> (self-service key grammar)
  + exactly one built-in user.display_name — the draft's org.role
  source is CUT (cross-context leakage into any RP's id_token);
- scope_name=openid forbidden (always granted = zero-authorization
  publication);
- custom-attributes document policy: object, depth <= 2, <= 8 KiB,
  ^[a-z][a-z0-9_]{0,63}$ keys, __proto__/constructor/prototype
  rejected (frontend renders via Map).

Publication chain: new common port IClaimMappingsProvider (additive,
IOrgContextResolver-shaped setter) consumed at all THREE issuance
surfaces — id_token code path, id_token REFRESH path (mappings
re-evaluated at rotation; disabling drops the claim from the next
refresh, the org_ctx O7 semantics), and userinfo (wraps the final
response, incl. the org_ctx branch). Mapped claims merge after the
standard set; the blacklist guarantees no collision. Domain layer keeps
its own scalar source-path walk (no drogon dependency); the plugin
wires StorageClaimMappingsProvider + the users-row profile resolver
(Postgres only; memory unwired = pre-P1-3 claim set byte-for-byte).

Discovery: claims_supported gains enabled mapping claims (plugin cache,
refreshed at startup + on mapping writes; auditClaimMapping refreshes).

Admin CRUD /api/admin/claim-mappings (list/create/update/delete;
roles:write + audit claim_mapping_created/updated/deleted; the three
red lines surface as CLAIM_MAPPING_RESERVED_CLAIM /
CLAIM_MAPPING_INVALID_SOURCE / CLAIM_MAPPING_FORBIDDEN_SCOPE).
Self-service GET/PUT /api/me/profile/custom-attributes (profile-gated;
memory 404; CUSTOM_ATTRIBUTES_INVALID on policy violations).

D5 v1.6.0 obligation: the legacy userinfo `username` dual-emission is
REMOVED (breaking; pre-announced in the 1.5.0 CHANGELOG) —
preferred_username remains; the gating test now asserts absence;
CHANGELOG [Unreleased] carries the Breaking entry.

Sync: error codes 3023-3026 across all 8 places; endpoint 5-point sync
(6 new ops incl. the two /api/me/custom-attributes; Property4
fingerprint regenerated exactly as the docs set = route manifest minus
ROUTE_ONLY/metrics/OPTIONS-only routes plus the two docs-only /docs/api
paths; route_manifest golden re-seeded authoritative); SDK regen;
openapi.json regen; api-diff baseline (one generated Users.h
dirtyFlag-literal churn ratified via --force); migration baseline
V001..V044; configuration-guide + zh mirror (claims section); llms.

Tests: unit (blacklist over all 23 reserved names, source whitelist
incl. the cut org.role, document policy incl. prototype trio/depth/
size/key grammar, scalar source resolution incl. non-scalar refusal);
integration (admin red lines through the CRUD; the full publication
chain — pre-mapping absence on id_token+userinfo, post-mapping
presence on userinfo, refresh re-evaluation carrying the claim, disable
dropping it from the next rotation; self-service PUT/GET round-trip +
policy rejections; userinfo no longer emits `username`). Full
in-process suite: 61736 assertions, 61734 passing — the two
stragglers (in different tests each run, both green in isolation on
the same binary+DB) are the known suite-order login rate-limit
threshold flake class observed since P1-4.

Fixes #280
…nsitive boost

drogon_ctl path resolution (ORM generation + CSP view builds):
- drogon's drogon_create_views() bakes the bare drogon_ctl name into
  build rules (PATH-resolved at build time), and drogon_ctl only ships
  inside the Conan cache package. The old hardcoded system-PATH entry
  into the cache died on any cache cleanup, breaking ORM generation,
  the main build (MSB8066/9009 at CSP step), and ctest's
  SdkSmoke.FullStack example build.
- new shared helper scripts/backend/ensure_drogon_ctl.bat (Windows
  counterpart of ensure_drogon_ctl() in the sh env common module):
  PATH first, then glob the Conan cache drogon package (both p/bin and
  p/<pkgid>/bin layouts; cache folder names are not stable across
  rebuilds).
- wired into generate_models.bat, build.bat (after conan install), and
  test.bat (SdkSmoke builds inside ctest).

boost removal:
- drogon 1.9.13 upstream no longer uses Boost anywhere; the conancenter
  recipe's with_boost=True default only dragged boost/1.83.0 into the
  graph and Boost::test_exec_monitor's main() into MSVC link lines.
- set drogon/*:with_boost=False; conan.lock drops boost/1.83.0 (19 -> 18
  pins, all other revisions unchanged); updated the two stale
  test_exec_monitor comments in libs/drogon/test accordingly.
generate_models.bat's designed end state is headers ONLY under
include/fulla/storage/postgres/models/ and .cc only under src/models/
(drogon_ctl writes both into src/models, then the script moves the .h
out). 27 src/models/*.h duplicates of the include/ headers were
committed by mistake long ago and survived because regeneration kept
overwriting them in place; running setup_database.bat +
generate_models.bat from a clean DB finally performed the designed
consolidation and flagged them as deletions.

Verified: all 27 deleted files are byte-identical at HEAD to their
include/ counterparts, and the regenerated models differ from HEAD by
zero content (EOL-only on 2 files) — i.e. the schema-first workflow
(migrations -> setup_database -> generate_models) reproduces the
committed models exactly; no model was ever hand-maintained.
…redirect_uris

Modern Chrome/Edge enforce the form-action directive across the redirect
that follows a form submission, so the strict 'form-action self' on the
server-rendered login page cancelled the 302 to the relying party's
redirect_uri after EVERY successful browser login (net::ERR_ABORTED;
the browser stays on the login form). The json-mode interop drivers
drive the token endpoints directly and structurally cannot catch this —
it surfaced live in the P1-5 nextjs-demo Playwright smoke.

The login page is an OAuth broker: after its form POST the browser must
be allowed to land on any REGISTERED redirect_uri (arbitrary https
origins in production, loopback origins for dev). It now carries
    form-action 'self' https: http://localhost:* http://127.0.0.1:*
which keeps plain-http credential exfiltration blocked. All other
fulla-hosted HTML keeps the strict profile.

Locked by Integration_Bootstrap_SecurityHeaders_LoginPageIsOauthBroker
(CSP shape + unchanged script/frame directives).
…written auth layer (P1-5 #282)

clients/ts publishes as npm fulla-oauth2 with two surfaces:

- the generated tree: every endpoint, typed, generated from the single
  OpenAPI spec by pinned @hey-api/openapi-ts 0.99.0 (pure Node, no JVM —
  typescript pinned to the 5.x line; the 7.x native rewrite drops the
  compiler API the generator reads). The emitted fetch client is fully
  self-contained (relative imports only) — the package has ZERO runtime
  dependencies.
- the handwritten layer (client-sdk D5): authorization-code flow with
  PKCE S256 + state/nonce — the callback completion helper rejects
  state mismatches and error callbacks BEFORE any network call; the
  code exchange validates the RFC 7636 verifier shape early;
  confidential clients authenticate via HTTP Basic (F-017), secret
  never in the body; refresh surfaces invalid_grant on rotation replay;
  M2MClient does client_credentials with proactive refresh under clock
  skew, promise-chained refresh serialization, and exactly one
  401-triggered retry; the end-session helper targets the
  discovery-documented /oauth2/end_session.

Gates (all green):
- vitest (house runner, no new toolchain): 20 red-line tests incl. the
  RFC 7636 Appendix B test vector, byte-exact redirect_uri round-trip,
  replay/loop call-count locks.
- the client regen tool grows --ts-only: regenerates via the LOCAL
  pinned install into a temp dir and tree-compares (generated import
  extensions follow the clients/ts tsconfig — part of the determinism
  contract).
- clients/ts package manifest joins the version linkage (now
  pyproject == package.json == cmake; the release --version-only
  backstop picks it up automatically; selftest extended).
- clients-sdk.yml adds a ts job (npm ci -> drift gate -> build -> vitest).
- npm-publish workflow: manual workflow_dispatch (decision D7) — build,
  pack and gates always run; publish only once NPM_TOKEN exists,
  otherwise a notice. Package name reserved early on npmjs.com.
…river (P1-5 #282)

examples/nextjs-demo is the reference third-party web app: Next.js App
Router, port 8080 (the seeded fulla-portal client's registered redirect
family), consuming fulla-oauth2 exactly as an npm consumer would (file:
dependency against the built dist; PUBLIC client + PKCE, so the repo
carries zero secrets and .env.example passes the hygiene gate).

Chain: landing -> login (SDK builds the authorize URL with state +
nonce + PKCE) -> /callback (SDK verifies state and completes the code
exchange server-side; tokens go to an httpOnly cookie, the browser
never sees them) -> /profile (userinfo) -> /api/me (Bearer-protected
route) -> /api/refresh (rotation leg, whitelist-bounded redirect) ->
end-session logout.

Its Playwright smoke becomes the FOURTH interop driver: unlike the
three json-mode RP libraries it drives a real browser through cookies,
redirects and the actual login form — which is how it caught the login
page CSP form-action bug fixed in the previous commit. interop.yml
builds the SDK + demo, boots next start on 8080 and runs the spec; the
interop README documents the driver table addition.

README path-D section now covers the TS SDK + demo; CHANGELOG carries
the SDK, demo and CSP-fix entries.
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-27T09:16:34.208404Z
We couldn’t complete this review. Comment @codex review to try again.
880b3e0 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@voidvec
voidvec changed the base branch from feat/p1-3-claims-mapping to master October 28, 2026 02:09
…ity headers

The gcov/coverage job runs the suite against the IN-PROCESS test server,
which does not register bootstrap::setupSecurityHeaders — the /login
answer there carries no CSP header by construction. That is a deployment
shape difference, not a regression: skip the broker-profile assertions
when the header is absent (out-of-process runs still assert fully).
Found on PR #294's coverage run (4 CHECK failures, all csp.empty-derived).
… ceilings

The gcov-instrumented in-process server cannot sustain the full-suite
load: at a 900s ceiling the OAuth2Tests run still ended (Timeout) with
logins null-resp through both retries — a throughput limit, not a
timeout-tuning problem. Correctness stays gated by the Build&Test matrix
and ASan/UBSan; this leg produces the COVERAGE NUMBER, so its tests now
run non-gating (loud warning, workflow green) in two passes — everything
except OAuth2Tests first, then OAuth2Tests with its own ceiling — so the
fast tests' coverage is never lost to the big binary's fate.
… connection (~5x slower)

With one shared client the 61k-assertion run slowed enough that Release
legs hit ctest/job ceilings (linux: OAuth2Tests Timeout 1200s where the
whole suite previously ran ~4 minutes). Per-request clients are back;
the null-resp tail those clients occasionally produced is absorbed by
the two retry layers (transport + whole-login) added earlier.
@voidvec
voidvec merged commit b16afdf into master Oct 29, 2026
13 of 15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant