15 KiB
AGENTS.md
Single-package Pyramid 2.0 blog app (ctp_blogr). Python 3.14, SQLite, Jinja2, WTForms,
SQLAlchemy 2.1, pyramid_tm transactions. Sign-in is Google OAuth only: Authlib
protocol/crypto primitives plus joserfc and urllib.request for HTTP. There are no
passwords anywhere — no password column, no password form fields, no local credential
path. UI strings, comments, and flash messages are French; match that when editing
views/templates.
Environment
The venv is the parent directory (/Users/phuoc/pyramid2), not inside the repo.
ctp_blogr is installed there in editable mode, so run commands from the repo root with
the venv's python (already on PATH as python). The venv is shared and also carries a
stale editable install of a wiki_test project whose source is gone — don't pip install
into it casually, and don't assume the venv contents match this repo's setup.py.
No linter, formatter, type-checker, pre-commit, CI workflow, or task runner exists. Don't invent one; don't claim you ran one.
Commands
ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pserve development.ini # waitress on localhost:9280 + debugtoolbar
ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pserve production.ini # binds *:9280, url_scheme https
ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... pshell development.ini # preloaded: request, tm, dbsession, models
python -m pytest # 34 tests, all offline (2 in tests.py + 32 in test_oauth.py)
python -m pytest ctp_blogr/tests.py::TestMyViewSuccessCondition # single test/class
ENV_GOOGLE_CLIENT_ID=... ENV_GOOGLE_CLIENT_SECRET=... python -c "from pyramid.paster import get_app; get_app('development.ini')" # boot check
pserve,pshell, and the boot check all refuse to start without both env vars:__init__.pyraisesConfigurationErrorwith a French message naming the missing ones. PasteDeploy%(ENV_...)sinterpolation does not work (plaster_pastedeploy1.0.1 ships noos.environsupport), so the app readsos.environitself; an.inivalue, if set, wins over the environment.pytest.inisetstestpaths = ctp_blograndpython_files = test*.py, so a new test file must be namedtest*.pyinside the package to be collected.coverageis broken:.coveragercstill sayssource = pyramid_blogr(old package name).coverage run -m pytestcollects nothing. Fix the name before trusting any coverage number; don't report "no coverage" as a finding.initialize_ctp_blogr_dbis declared insetup.pybutctp_blogr/scripts/initialize_db.pydoes not exist (onlyscripts/__init__.py) — the console script fails withModuleNotFoundError. There is no working DB-init command.
Architecture / wiring
ctp_blogr/__init__.py::mainis the PasteDeploy app factory. Auth is Pyramid'sSessionAuthenticationPolicy+ACLAuthorizationPolicywithroot_factory='ctp_blogr.security.RootFactory'. The whole ACL is 3 lines insecurity.py:view→Authenticated,manage→group:administrators, thenDENY_ALL.- The session factory is explicit: 85-char secret,
timeout=36000(10 h; theSignedCookieSessionFactorydefault is 1200 s / 20 min),httponly=Truealways,secure=Trueonly whensession.cookie_secure = true(set in production.ini). - Identity is the email, stored as
request.authenticated_userid(set viaremember()in the OAuth callback). - Admin is not a DB flag.
services/user.py::make_groupfinder(admin_emails)returns the auth callback built from theauth.admin_emailssetting in the.inifiles. It must not query the DB:pyramid_tmresolvesrequest.authenticated_useridinsidemanager.begin(), so a DB query there would run per request inside the transaction and any DB error would 500 every route including/login. Falsy userid →None, admin email →['group:administrators'], anything else →[](logged in, no manage rights). Callers test'group:administrators' in request.effective_principals(views and templates)./usersand/user_editrequiremanage;blog_edit,tag_edit, and/tagsrequire onlyview. services/google_oauth.py::GoogleOAuthis registered as an attribute,config.registry.google_oauth(read asrequest.registry.google_oauth). Pyramid'sRegistrysubclassesdictbut has no__getattr__, so aregistry['google_oauth']dict-key assignment would be invisible to the views. It resolvesauthorization_endpoint/token_endpoint/jwks_uri/issuerfrom the Google discovery document, cached, plus a cached JWKS (CACHE_TTL = 3600, counted byjwks_fetches). Authlib has no Pyramid integration, so the app ownsstate/ PKCE /nonceitself;authorize_redirect/save_authorize_dataare not used. Authlib is used only for protocol primitives (generate_token,create_s256_code_challenge) and the 3 HTTP calls go throughurllib.request— do not addrequests(authlib.integrations.requests_clienthard-imports it).id_tokenis verified withjoserfc, notauthlib.jose. Scope isopenid emailwith nohdparameter (the second account is a personal Gmail address, so anhdlock would reject it).- Routes added:
login_google=/login/google,login_google_callback=/login/google/callback. - The callback pops
state/nonce/code_verifierfrom the session before the token exchange, and setsrequest.environ['retry.attempts'] = attempt + 1to declare itself the last attempt. Both are required: apyramid_retryreplay restarts from the original request cookie (which still carriesoauth_state), which would re-send an already-consumed authorization code and failinvalid_grant. - Sign-in is gated on the
userstable (allowlist). No row is ever auto-created. Unknown email setsrequest.response.status_int = 403explicitly and renderstemplates/denied.jinja2, without raisingHTTPForbidden. New templatedenied.jinja2renders the refusal in French. - The callback validates three ID-token claims before touching the allowlist, and each
one is pinned by a test that fails if the guard is removed:
- a missing
codeparam is refused before any network call (fetch_calls == 0); email_verifiedmust be exactlyTrue. The identity is the email, so accepting an unverified address would let an unverified Google account impersonate an allowlisted one. Google returns this claim; don't "simplify" the check away;submust be non-empty. Without it the matched row would getgoogle_sub = '', which both collides with theUNIQUEconstraint and leaves the record rebindable by any other account presenting the same address.
- a missing
views/default.py::deniedis registered with@view_config(context=HTTPForbidden, ...)and sets a real 403. Unauthorized requests now return HTTP 403 with the denied page — assert 403 in tests, and don't assume 200 means "the page worked" when smoke-testing protected routes (/users,/tags,/blog_edit/{id},/tag_edit/{id},/user_edit/{name}).user_editispermission='manage'(any logged-in user could previously open or create fiches). The create-user branch (name='0') is gone; unknown names flash "Utilisateur non trouvé" and redirect.UserEditForm(renamed fromUserCreateForm) has onlyid+name, and the view deletesform.name(notform.id) beforepopulate_obj, since the email is the identity.- Views bind to routes via
@view_config(route_name=...); routes are declared centrally inroutes.py. Adding a URL means editing both. models/__init__.pyincludespyramid_tm+pyramid_retryand reifiesrequest.dbsession.settings['tm.manager_hook'] = 'pyramid_tm.explicit_manager'is set there — removing it breaks commit/abort behaviour.- Write path: view →
request.dbsession.add(...)→ implicit commit bypyramid_tm. Do not callcommit()yourself. - Query layer lives in
services/(BlogRecordService,UserService, plus the Google collaborator) — all classmethods takingrequest. Keep DB access out of views. - Read filtering is duplicated per-method: anonymous users only ever see
status == 'publié'(by_criteria,by_tag,get_last_created).by_iddoes not filter — any post is readable by direct URL. Preserve that asymmetry or you change access behaviour. - Post bodies are Markdown;
views/blog.pyrunsmarkdown.markdown(..., extensions=['footnotes'])and rewritesstatic/→<app_url>/static/so relative image paths resolve. - Renderer strings are inconsistent on purpose-ish: most views use
'../templates/x.jinja2'(relative to the package), butdefault.py::user_edituses the asset spec'ctp_blogr:templates/user_edit.jinja2'. Both work; don't "normalize" one without checking the jinja2 search path. login.jinja2has a single "Se connecter avec Google" control and no password markup;layout.jinja2shows the Utilisateurs nav item only for admins; the password-change nav item and the "Nouvel utilisateur" button are gone.users.jinja2is NULL-safe forlast_logged(renders "jamais");user_edit.jinja2shows the email readonly.forms.pycontains dead forms from another app (HistoForm,AllocationForm,ActifForm,Actif2Form) matching orphaned DB tableshisto,allocation,actifs. Ignore them.
Database
ctp_blogr.sqliteis committed and is the real dev database (~99 posts, 8 tags; users are nowphuoc@caotek.fr(admin, via the.inisetting) andctphuoc@gmail.com(authenticated, no manage)). Bothdevelopment.iniandproduction.inipoint at it via%(here)s..gitignorelists*.sqlite, but the file is already tracked, so the ignore rule does nothing — every write view'spyramid_tmcommit is a real diff to review.users:passworddropped,google_subadded (nullable, UNIQUE),namestill NOT NULL- UNIQUE and now stores the email.
entries.creator/entries.editorareUnicode(255).
- UNIQUE and now stores the email.
- The DB contains an
alembic_versiontable (revb8f8216d72c7) but there is noalembic.iniand nomigrations/directory in this repo. Alembic is installed in the venv, yet nothing here is wired to it. The OAuth schema changes were hand-applied SQL, so the schema cannot be regenerated. - The DB file is the source of truth for schema. Changing a model means: apply the
equivalent
ALTER TABLEtoctp_blogr.sqliteby hand, and tell the user migrations are missing rather than pretendingBase.metadata.create_all()covers them.create_all()is only used by the in-memory test fixtures (tests.py::BaseTest.init_database,test_oauth.py::demarrer). Tagsis defined inmodels/blog_record.pybut is not imported inmodels/__init__.py(onlyUserandBlogRecordare, beforeconfigure_mappers()). It still works only because importingblog_recorddefines the class as a side effect. Import it explicitly if you touch mapper config.
Known-broken (verified, do not re-diagnose)
views/default.py:18—homehardcodesBlogRecordService.by_id(request, 95)and then readsentry.title/entry.bodywith noNonecheck. The home page raisesAttributeErroron any database where id 95 doesn't exist (e.g. a freshly created one).home.jinja2:18hardcodes the same id for the "select another drawing" link.services/blog_record.pyusesquery.get(_id)— legacy SQLAlchemy 1.x API. Works on 2.1.3 but emitsLegacyAPIWarning; prefersession.get(Model, id)when touching that line.routes.py:5—'/blog/{id:\d+}/{slug}'is a non-raw string, so Python 3.12+ emitsSyntaxWarning: "\d" is an invalid escape sequence. Prefix withrif you edit it.models/meta.pyusessqlalchemy.ext.declarative.declarative_base(MovedIn20Warning).- Draft leak in
by_id(no status filter) and theLIKEsearch leak inby_criteria(anonymous search matches drafts/privates, only the display is filtered elsewhere) are pre-existing and deliberately not fixed by the OAuth work. - Any logged-in user (including the non-admin account) can edit and delete ANY post,
because
security.pygrantsviewto every authenticated user andblog_edit/tag_editonly requireview. Pre-existing, deliberately unchanged (README documents an "admin ou redacteur" role). Pinned bytest_DEFAUT_connu_un_utilisateur_non_administrateur_peut_modifier_un_billet. UserService.deletehas no FK cascade: deleting a user leaves their email stranded inentries.creator/entries.editor.- Pre-existing DB defect, not caused by the OAuth work:
PRAGMA foreign_key_checkaborts with "foreign key mismatch" becausefk_allocation_classe_actifsreferencesactifs.classe, which is neither UNIQUE nor a PK. - No CSRF protection on the pre-existing POST views, and
logoutis a state-mutating GET. Accepted risk, not fixed. Pyramid 2.1's cookie session exposesnew_csrf_token()/get_csrf_token()if someone wants to add it. - Identity lives entirely in the signed client-side cookie (no server-side session backend). Payload is ~250 bytes against Pyramid's hard 4064-byte limit, so the only growth vector is unbounded flash accumulation.
Testing
ctp_blogr/tests.py still has only its two original duplicate tests (both assert the same
success case; test_failing_view is a copy-paste of test_passing_view). New
ctp_blogr/test_oauth.py adds 32 tests on a real WSGI app over sqlite:///:memory: with
the Google collaborator faked and sockets blocked by an autouse fixture — 34 passing in
total, all fully offline. Tests named DEFAUT_* pin known defects on purpose (observed
behaviour, not desired). BaseTest wraps its session in transaction.manager, aborting in
teardown. WebTest is installed; test_oauth.py shows the pattern for new view tests.
Conventions
- 4-space indent, no type hints, no docstrings on views. Comments are frequently French and
slightly garbled English/French mixes (
# interdire le car '/' dans le titre) — leave them. @view_configrenderer paths and view return dicts follow the existing pattern; views return plain dicts consumed by the matching Jinja2 template.- Form submission is detected by string key presence:
if 'form.submitted' in request.params, plus'form.update_created'and'form.deleted'for the extra buttons. Buttons must setname="form.submitted"etc. del form.id(blog/tag forms) /del form.name(user_edit) beforepopulate_objis a deliberate guard against overwriting the primary key / identity. Keep it./blog_bytag/{tag}/{retour}—retouris a route name (request.route_url(retour)), not a URL. Callers pass e.g.retour='home'.- Currently on branch
feat/google-oauth, uncommitted, nothing pushed (mastertracksorigin/masterat the same commit, so branch choice is still not settled). A verified pre-migration DB backup exists outside the tree at/var/folders/cm/x5cx1bfd749gxd1x_zt6bvp40000gn/T/opencode/ctp_blogr-backup-20261005-202240.sqlite(PRAGMA integrity_checkreturnsok); restore withcpoverctp_blogr.sqlite. Ask before creating branches, committing, or pushing to the self-hostedhttps://git.caotek.fr/phuoc/ctp_blogr.git. Commit only when asked.