56 KiB
google-oauth-migration - Work Plan
TL;DR (For humans)
What you'll get: Password login is gone. Visitors sign in with a Google account, and only the two email addresses you already have in the database are allowed in. Signing in as phuoc@caotek.fr keeps admin powers; signing in as ctphuoc@gmail.com gets you in without them.
Why this approach: The app keeps its own list of permitted emails and checks it once, at sign-in, so Google's "test user" loophole cannot be used to walk in. Authorization no longer decides who is an admin by comparing a name against the word "admin"; it reads a configured list instead. Sign-in state is protected with Google's recommended anti-hijacking checks.
What it will NOT do: It will not create accounts for strangers, ask Google for any extra information about you, add a server-side session system, or introduce a database migration tool that the project does not have.
Effort: Large Risk: High - it replaces the entire sign-in mechanism and rebuilds a database table inside a database file that is tracked in version control. Decisions to sanity-check: Your admin rights move from a hardcoded name to a list you edit in a config file; the sign-in screen timeout is lengthened so slow Google sign-ins are not thrown away; a broken edit-user page and a crash on the user list are fixed along the way because they sit in the code being changed.
Your next move: approve, or run a high-accuracy review. Full execution detail follows below.
TL;DR (machine): Large/high-risk Pyramid 2.1 auth swap to Google OIDC via Authlib, DB allowlist gate, destructive SQLite users-table rebuild, 14 todos in 5 waves + F1-F4.
Scope
Must have
- Google OAuth 2.0 / OIDC sign-in replacing password login entirely. Login becomes a redirect to Google; there is no local credential path left in the codebase.
- Authlib 1.8.0 as the only new library, driven by Google's OpenID discovery document at
https://accounts.google.com/.well-known/openid-configurationsoauthorize_url,access_token_urlandjwks_uriare never hand-written. It is a requests-based client (authlib.integrations.requests_client.OAuth2Session); Authlib ships integrations for Flask, Django and Starlette but none for Pyramid, which is why the app owns its ownstate/ PKCE /noncehandling. The app fetches the discovery document itself and passes the resolvedauthorization_endpoint,token_endpointandjwks_uriinto the client: passingserver_metadata_url=toOAuth2Sessiononly stores inert metadata, becauseload_server_metadataexists solely on the Starlette-stylesync_app/async_appclients. The document is cached alongside the JWKS rather than refetched per sign-in. - A named, registry-registered collaborator for the Google exchange - not ad-hoc monkeypatching
in tests. It exposes
create_authorization_url(...)andfetch_claims(...), and it fetches and caches the JWKS itself, because the bare client does not:CodeIDToken+jwt.decoderequire the app to pull keys fromjwks_uri, and an unbounded refetch on every sign-in is a latency and rate-limit problem. Cache TTL is a decision of this plan, not left implicit. - App-owned
state, PKCE andnoncehandling.stateis generated, persisted in the session and consumed (deleted) before the token exchange is attempted;code_verifieris generated by the app withgenerate_token(48)becausecreate_authorization_urlreturns only(uri, state)and never hands the verifier back;nonceis generated, persisted, compared against theid_tokenclaim, then deleted. nonceis REQUIRED by Google's OIDC request parameters.- PKCE with
code_challenge_method=S256, thecode_verifierpersisted between the two hops. - A single, non-replayable code exchange.
pyramid_tmtags exceptions as retryable, so a SQLite "database is locked" transient error can makepyramid_retryre-run the callback and re-exchange an already-used authorization code, which fails withinvalid_grantand shows the user a broken login. The callback must be non-retryable andfetch_claimsmust be called at most once per request even with retries enabled. - Auth policy swap to Pyramid's built-in
SessionAuthenticationPolicy+SessionAuthenticationHelper, replacingAuthTktAuthenticationPolicy. Pyramid 2.1 ships both with the same three-method interface asAuthTktCookieHelper, soremember()/forget()call sites keep working andsecurity.pyis untouched. The hardcoded auth-ticket secret is removed. - The session factory is explicitly reconfigured - it is a first-class part of this change, not
an afterthought.
SignedCookieSessionFactory('hZug2zPt7hT2MZ')defaults totimeout=1200(20 minutes) while the outgoingAuthTktAuthenticationPolicyused36000(10 hours). A Google consent screen, a Google sign-in and a second factor routinely exceed 20 minutes, so the OAuth values would expire mid-flow. The factory must be constructed with an explicit, longertimeout, a secret of at least 64 characters forhashalg='sha512'(it is currently 16), andhttponly=Truein both environments plussecure=Trueunder production settings. Today the cookie carriessecure=False,httponly=Falseeven thoughproduction.inideclaresurl_scheme = https; once the cookie is the identity, third-party CDN scripts inlayout.jinja2:14-15,106-107can read it via XSS. - No session-id rotation is attempted, because the API does not exist.
request.session.renew()is not part of Pyramid 2.1, and neither are theSessionRenew/SessionInvalidateexceptions. The only primitive isinvalidate(), which is literallyself.clear()carrying the upstream comment# XXX probably needs to unset cookie. There is no server-side session id, so classic session fixation is structurally impossible; the real requirement is to delete thestate/code_verifier/noncekeys after the callback so they do not survive client-side. remember()stores the user's email asauthenticated_userid, which keeps all 15 existingrequest.authenticated_useridcall sites working - 7 in Python (services/blog_record.pyx3,views/blog.pyx2,views/default.pyx2) and 8 in templates (layout.jinja2x4,blog.jinja2x2,home.jinja2x1,user_edit.jinja2x1) - includinglayout.jinja2:46(routes/user_edit/{name}by that value) andviews/blog.py:64,75(already write it intoentries.creator/entries.editor).- A database allowlist as the mandatory access control, checked exactly once, in the callback.
An email that does not match a pre-provisioned
usersrow is rejected and no row is created. This is required, not defensive: Google's own production-readiness guidance states the consent-screen test-user allowlist is bypassed when an app requests only basic identity scopes. - The allowlist check must NOT become the group lookup.
pyramid_tmdefaultstm.annotate_usertoTrueand then callsrequest.authenticated_useridinsidemanager.begin(). A database-backed groupfinder therefore runs a SELECT on every single request, inside the transaction, and any database error turns every route - including/loginand the OAuth callback itself - into a 500. This was reproduced against the real app. - Admin rights become configured data, not a literal and not a database lookup. All 4 sites
comparing to the literal
'admin'are replaced by'group:administrators' in request.effective_principals:services/user.py:26,views/default.py:93,templates/layout.jinja2:43,templates/user_edit.jinja2:45. The mapping from email to admin comes from anauth.admin_emailslist in settings, so it survives a session loss, takes effect immediately on edit, and costs no query. Templates cannot call a groupfinder, which is why theeffective_principalscheck is the correct template-side expression. - One-shot binding of the two existing accounts, with no first-login claim window:
admin(id 1) ->phuoc@caotek.fr(retainsmanage);phuoc(id 2) ->ctphuoc@gmail.com(authenticated, nomanage). Two distinct people, so each row maps to exactly one identity. The Googlesubis stored on first successful sign-in and matched first on later sign-ins, becausesubis Google's stable identifier and email is mutable. - Removal of the password surface: the
users.passwordcolumn, thepasslibdependency, the top-levelimport passlibinmodels/user.py:11(removing the dependency without removing that import breaks the entire app at import time),User.verify_password/set_password, thepassword/confirmWTForms fields and their length check atviews/default.py:116-117, the non-admin self-service password path, and the admin "Nouvel utilisateur" create-user flow atviews/default.py:98-102,119-131. - Every template that renders a removed field is fixed in the same change. Jinja2's default
Undefinedraises on attribute access, souser_edit.jinja2:27-28,36-37will raiseUndefinedErrorthe moment the password fields disappear. last_loggedgets an explicit writer and a NULL-safe renderer.User.verify_password(models/user.py:29) is currently the only code in the app that writes it, so deleting that method deletes the only writer - andtemplates/users.jinja2:27callsentry.last_logged.strftime(...)with no NULL guard, which was verified to raiseUndefinedError. The callback must setlast_logged, and/usersmust survive a NULL.- The pre-existing
delete(...)NameErroratviews/default.py:142is fixed in this change. It sits insideuser_edit, which this migration edits regardless (lines 93, 98-102, 116-117, 119-131, 127, 137).UserService.deletealready exists atservices/user.py:18. The trap: if the'admin'literal inuser_edit.jinja2:45is swapped for theeffective_principalscheck, the delete button still renders for admins and theNameErrorsurvives the migration. - Hand-written schema change against the committed
ctp_blogr.sqlite, with an explicit warning that this repo has noalembic.iniand nomigrations/directory, so the schema cannot be regenerated. A SQLite table rebuild (not a plainALTER TABLE) is required becauseusers.passwordisnullable=False.entries.creator/entries.editorareUnicode(50)and are written straight fromrequest.authenticated_useridwith no length guard, so they are widened in the same rebuild. - A branch decision and a documented restore path for
ctp_blogr.sqliteare prerequisites, not afterthoughts. There is a single commit onmasterwithmainandorigin/*at the same commit, and this migration performs a destructive rebuild on a tracked file. scope=openid emailonly.profileis not requested (no display name or avatar is used).hdis deliberately omitted: it means "a Workspace account on this domain", andctphuoc@gmail.comis a personal Gmail account, so settinghd=caotek.frwould lock the second user out. The database allowlist is the control that actually works.- A brand-new WebTest harness. The existing
BaseTestbuilds atesting.DummyRequest, not a WSGI app, so WebTest cannot drive it - andBaseTest.init_database()is defined but never called by either existing test, so no table is ever created. "WebTest on the existing fixture" is impossible; the plan therefore builds its own WSGI app onsqlite:///:memory:with the Google collaborator stubbed andpyramid_retrydisabled, covering the happy path and every failure path. - All user-visible strings, comments and flash messages stay in French, matching the app.
Must NOT have (guardrails, anti-slop, scope boundaries)
- No implicit sign-up. A Google identity that is not already in
usersis refused; it is never auto-created. This closes the self-registration hole that a naive OAuth migration opens. - No
profilescope, no avatar, no display name, no refresh token, noaccess_type=offline, nohdparameter, and no call to any Google API. Sign-in only. - No Google-side logout or token revocation. Logout is local session invalidation only.
- No database-backed groupfinder and no
tm.annotate_userchange. The login allowlist is a DB read performed once in the callback; the group mapping is configuration. Silencingannotate_userto dodge the tween problem would break commit/abort semantics and is forbidden. - No
pyramid_csrfpackage and no retrofitting CSRF onto the existing write views. Pyramid 2.1's cookie session already providesnew_csrf_token()/get_csrf_token()under_csrft_, so no dependency is needed - but the token is a single long-lived per-session value with no automatic comparison helper, and wiring it into every pre-existing POST view is an unrelated refactor.stateis the control for the callback itself (a single-use GET bound to the pre-authentication session). The pre-existing absence of CSRF, andlogoutbeing a state-mutating GET, are recorded as accepted risks, not silently fixed and not silently ignored. - No server-side session backend. No
pyramid_beaker. Measured, not assumed: identity plusstate+code_verifier+nonceserializes to 324 bytes; with ten French flash messages, 662. Pyramid raises a hardValueErrorat 4064 bytes, leaving ~3.7 KB of headroom. The only growth vector is unbounded flash accumulation, which is bounded by capping flash length, not by adding a backend. - No migration tooling. Do not introduce Alembic, do not create a
migrations/directory, and do not pretendBase.metadata.create_all()covers the production schema. - No use of coverage as an acceptance gate.
.coveragercstill sayssource = pyramid_blogr(the old package name) so coverage collects nothing; the number would be fiction. Fix the name only if asked. - No unrelated refactors beyond the lines this migration must already touch. Explicitly
out of scope: the hardcoded post id
95inhomeandhome.jinja2:18, the legacyQuery.get()call inservices/blog_record.py, the non-raw string atroutes.py:5, the legacydeclarative_baseinmodels/meta.py, the missinginitialize_db.pybehind the declaredinitialize_ctp_blogr_dbconsole script, and the deadHistoForm/AllocationForm/ActifForm/Actif2Formforms matching orphaned tables. The pre-existing draft-leak inby_id(no status filter) and theLIKEsearch leak are recorded, not fixed: they are unchanged by this migration, and widening the user population is what makes them worth a separate decision. - No invented linting, formatting, type-checking, CI or task runner. None exists and this migration does not create any.
- No reduction to an MVP or phase 1. This is the complete migration, one plan.
Verification strategy
Zero human intervention for everything the agent can execute. The single exception is F3's real Google sign-in, which needs credentials only the user can issue - see the prerequisite below. Todos 1-14 all run fully offline against a stubbed Google collaborator.
User prerequisite, required before F3 only (not before Wave 1): provision a Google Cloud OAuth 2.0 client, add the Web application redirect URI
http://localhost:9280/login/google/callback, and setENV_GOOGLE_CLIENT_ID/ENV_GOOGLE_CLIENT_SECRET. Todos 1-14 do not need these; F3 cannot pass without them.
- Test decision: tests-after, WebTest + pytest, on
sqlite:///:memory:via a newly built WSGI test app (the existingBaseTestcannot be reused - see Scope). The Google collaborator and the JWKS fetch are injected behind the named seam from todo 7 so no test performs network I/O.pyramid_retryis disabled in tests, because single-shot side effects such as consumingstateare otherwise non-deterministic. - Never assert a bare 200 in this app. Unauthorized requests return 200 with the login page,
not 403, because a
forbidden_view_configswallowsHTTPForbidden. Every protected-route assertion checks status and body content. - Per-todo happy AND failure QA, each writing evidence to
<attemptDir>/task-<N>-google-oauth-migration.<ext>. - Structural self-check before handoff: every implementation row is column-zero
- [ ] N. <title>and every final-verifier row is column-zero- [ ] F<number>. <title>, both inside their intended section, with no prose bullet standing in for a task.
Acceptance criteria (transcribed from the gap analysis, grouped)
Environment / boot
python -c "from pyramid.paster import get_app; get_app('development.ini')"exits 0, andpython -c "import authlib, joserfc; print(authlib.__version__)"succeeds.python -m pytest -qreports the exact new count,0 failed, and nono such tableerror (which proves the new harness actually creates the schema).
Session policy
3. The post-login cookie, read on the next request, contains auth.userid and no key
matching state|code_verifier|nonce.
4. A session older than the configured timeout is rejected, asserted with a monkeypatched clock
rather than a sleep.
5. The factory is constructed with secure=True under production settings and httponly=True in
both; the configured timeout is asserted to exceed the previous 20-minute default and its
value is written down.
Callback
6. Unknown email -> assert the actual status (403), that the body contains the French rejection
message, and that it contains no <input type="password">.
7. state mismatch, missing state, and replayed state each fail closed, call the token endpoint
zero times, and leave no session identity.
8. nonce mismatch is rejected; assert the JWT nonce claim is compared against the stored value
(the stub returns a mismatching claim).
9. ?error=access_denied -> French flash, no DB write, no session identity.
10. Retry safety: with retry.attempts=3, a transient error during the callback does not
produce a second exchange (assert collaborator call count == 1).
Access control
11. Authenticated as ctphuoc@gmail.com: GET /user_edit/phuoc@caotek.fr is refused,
GET /user_edit/0 is refused, GET /users does not contain users_list.
12. Same user: POST /blog_edit/<id> with form.submitted does not modify the post, and with
form.deleted the row is still present.
13. users.name is restricted to the two allowlisted emails, and
groupfinder('ghost@nowhere.test', request) returns None.
Templates / removals
14. GET /users returns 200 with a row whose last_logged is NULL in the database (no strftime
UndefinedError).
15. GET /user_edit/<email> returns 200 and the HTML contains no name="password".
16. grep -c "route_url('user_edit', name='0')" ctp_blogr/templates/users.jinja2 == 0 and
GET /user_edit/0 is unreachable.
17. grep -rn passlib ctp_blogr/ returns nothing and the app still boots (the import-order
trap).
18. grep -rn "'admin'" ctp_blogr/ returns no authorization comparison.
Schema
19. After the rebuild, PRAGMA table_info(users) shows no password column and name still
NOT NULL + UNIQUE; SELECT COUNT(*) FROM users == 2 with the correct emails;
PRAGMA foreign_key_check is clean; the alembic_version row is still present and untouched.
20. PRAGMA table_info(entries) shows creator/editor widened.
Regression
21. python -m pytest ctp_blogr/tests.py still passes, or the two legacy duplicate tests are
deliberately deleted as part of the harness replacement.
22. An explicit smoke pass over all 15 routes asserting content, never a bare 200.
Execution strategy
Parallel execution waves
Waves are deliberately smaller than the 5-8 target. The gap analysis proved that the session factory, the auth policy and the group mapping are one coupled change (a 20-minute default timeout plus a database-backed callback inside the transaction tween), and that the destructive table rebuild must be isolated. Splitting those further would produce intermediate states that cannot boot.
- Wave 1 - prerequisites and foundation (no interdependencies): dependency manifest and
authlibinstall; branch decision plus database backup and restore path; session factory hardening; thesettingssurface for the client id/secret andauth.admin_emails. - Wave 2 - auth core (coupled, land together): the
SessionAuthenticationPolicyswap with the settings-based groupfinder; the destructive schema rebuild on the agreed branch; the named Google collaborator with JWKS caching. - Wave 3 - the flow (depends on Wave 2): the authorize redirect view; the callback view with
state consumption, nonce comparison and a single non-retryable exchange; the allowlist gate with
the explicit 403,
remember(), thelast_loggedwrite and OAuth-key cleanup. - Wave 4 - authorization gaps and surface removal (must land in the same change set as Wave 3):
the
/user_editpermission and ownership fix together with thedelete(...)NameError; the four literal'admin'sites; removal of the password surface and the template fixes it forces. - Wave 5 - tests (depends on all): the new WSGI harness and the full suite.
- Wave 6 - final verification wave (F1-F4), all four in parallel, all must APPROVE.
Dependency matrix
| Todo | Depends on | Blocks | Can parallelize with |
|---|---|---|---|
1. Dependency manifest + authlib install |
- | 7, 13 | 2, 3, 4 |
| 2. Branch decision + DB backup/restore path | - | 6 | 1, 3, 4 |
| 3. Session factory hardening (timeout, secret, httponly, secure) | - | 5, 8, 9 | 1, 2, 4 |
4. settings surface: client id/secret + auth.admin_emails |
- | 5, 7, 8 | 1, 2, 3 |
5. SessionAuthenticationPolicy + settings-based groupfinder |
3, 4 | 10, 11, 12 | 6, 7 |
6. Schema rebuild: drop password, add google_sub, widen creator/editor |
2 | 9, 10, 14 | 5, 7 |
| 7. Google collaborator seam + JWKS cache | 1, 4 | 8 | 5, 6 |
| 8. Authorize redirect + callback views | 3, 7 | 9, 13 | - |
9. Allowlist gate + explicit 403 + last_logged + key cleanup |
5, 6, 8 | 13 | - |
10. /user_edit authorization + delete(...) NameError |
5, 6, 9 | 13 | - |
11. Replace the 4 literal 'admin' sites |
5, 9 | 13 | 10 |
| 12. Remove the password surface + template fixes | 6, 10, 11 | 13 | - |
| 13. New WSGI test harness + full suite | 1-12 | F1-F4 | - |
14. AGENTS.md accuracy update |
13 | F1-F4 | - |
Todos
Implementation + Test = ONE todo. Never separate.
-
1. Declare and install
authlibwithout corrupting the shared venv What to do / Must NOT do: Addauthlibtoinstall_requiresinsetup.py(which pulls injoserfc). Install it into the parent venv at/Users/phuoc/pyramid2. MUST NOT casuallypip installinto that venv -AGENTS.mdrecords that it is shared and still carries a stale editable install of awiki_testproject whose source is gone. MUST NOT add any other new dependency, and MUST NOT introducepyramid_csrforpyramid_beaker. Parallelization: Wave 1 | Blocked by: - | Blocks: 7, 13 References (executor has NO interview context - be exhaustive):setup.py(install_requires, currently declares the deadinitialize_ctp_blogr_dbconsole script whose targetctp_blogr/scripts/initialize_db.pydoes not exist);AGENTS.md"Environment" section; venv root is the parent directory,pythonis already on PATH. Acceptance criteria (agent-executable):python -c "import authlib, joserfc; print(authlib.__version__)"exits 0 and prints 1.8.0;python -c "from pyramid.paster import get_app; get_app('development.ini')"exits 0;git diff setup.pyshows exactly one added dependency line. QA scenarios (name the exact tool + invocation): happy -python -c "import authlib, joserfc"succeeds after install. failure -pip install authlib==0.0.0is NOT run; instead assertgrep -c "authlib" setup.py== 1 and thatpyramid_csrf/pyramid_beakerare absent. Evidence:<attemptDir>/task-1-google-oauth-migration.<ext>Commit: N - proposefeat(deps): add authlib for Google OAuth, commit only on explicit request. -
2. Decide the branch and establish a restore path for the tracked database What to do / Must NOT do: Surface the branch decision to the user and get an explicit answer before any schema work.
ctp_blogr.sqliteis tracked in git (the*.sqliteline in.gitignoredoes nothing for an already-tracked file), there is a single commit onmaster, andmainandorigin/*point at it. Produce a dated backup of the database file outside the repo and record the exact restore command. MUST NOT create a branch, commit, or push without the user's answer. MUST NOT modify the schema in this todo. Parallelization: Wave 1 | Blocked by: - | Blocks: 6 References (executor has NO interview context - be exhaustive):AGENTS.md"Database" and "Conventions" sections;development.iniandproduction.iniboth point at the file via%(here)s; the file containsalembic_versionrevb8f8216d72c7, ~99 posts and 8 tags. Acceptance criteria (agent-executable): a backup file exists outside the working tree andsqlite3 <backup> "PRAGMA integrity_check"returnsok; the restore command is written into the plan's commit strategy;git status --porcelain ctp_blogr.sqliteis still clean at the end of this todo. QA scenarios: happy - backup created and verified withPRAGMA integrity_check. failure - confirmgit checkout -- ctp_blogr.sqlitewould restore the pre-migration state, and record that the working tree is untouched so far. Evidence:<attemptDir>/task-2-google-oauth-migration.<ext>Commit: N - documentation only, no commit. -
3. Harden and reconfigure the session factory What to do / Must NOT do: Replace the bare
SignedCookieSessionFactory('hZug2zPt7hT2MZ')with an explicit construction: a secret of at least 64 characters forhashalg='sha512'(currently 16), an explicittimeoutlong enough to survive a Google consent screen plus sign-in plus 2FA (the default is 1200s / 20 min against the outgoing policy's 36000s / 10 h),httponly=Truein both environments, andsecure=Truedriven by settings so production gets it. MUST NOT addpyramid_beaker. MUST NOT callrequest.session.renew()- that method does not exist in Pyramid 2.1, and neither doSessionRenew/SessionInvalidate. MUST NOT leave the factory defaults in place, because the cookie becomes the identity. Parallelization: Wave 1 | Blocked by: - | Blocks: 5, 8, 9 References (executor has NO interview context - be exhaustive):ctp_blogr/__init__.py:13(the factory) and:15(the hardcoded auth-ticket secret being removed); the installed factory source confirms_timeout: 1200,_cookie_secure: False,_cookie_httponly: False,_cookie_samesite: 'Lax',_cookie_on_exception: True;production.ini:29declaresurl_scheme = httpswhile the cookie still ships noSecureflag;templates/layout.jinja2:14-15,106-107load third-party CDN scripts, sohttponly=Falseis a real XSS-to-identity-theft path once the cookie carries the identity.SameSite=Laxis already correct for a top-level GET callback - do not change it toNone. Acceptance criteria (agent-executable):grep -n "SignedCookieSessionFactory" ctp_blogr/__init__.pyshows named kwargs fortimeout,httponly,secureand the secret; the secret is >= 64 chars (assert with a Python one-liner);python -c "from pyramid.paster import get_app; get_app('development.ini')"exits 0; aSet-Cookiefrom a test request carriesHttpOnly, and carriesSecurewhen production settings are loaded. QA scenarios: happy - boot check passes and the header assertions hold. failure - assert a monkeypatched clock past the configuredtimeoutcauses the identity to be rejected (criterion 4), with nosleepin the test. Evidence:<attemptDir>/task-3-google-oauth-migration.<ext>Commit: N - proposefix(auth): harden and configure the signed session cookie. -
4. Add the
settingssurface for the Google client and the admin email list What to do / Must NOT do: Readgoogle.client_idandgoogle.client_secretfrom settings via PasteDeploy environment interpolation (%(ENV_GOOGLE_CLIENT_ID)s,%(ENV_GOOGLE_CLIENT_SECRET)s) in bothdevelopment.iniandproduction.ini, and addauth.admin_emailsas a parsed list (phuoc@caotek.fr). Fail fast with a clear French message if the credentials are missing. MUST NOT commit any real credential, and MUST NOT invent a settings key that nothing reads. Parallelization: Wave 1 | Blocked by: - | Blocks: 5, 7, 8 References (executor has NO interview context - be exhaustive):ctp_blogr/__init__.pymain()(readssettings);development.iniandproduction.ini;ctp_blogr/security.py(the 3-line ACL whosegroup:administratorsprincipal this list feeds). Acceptance criteria (agent-executable):grep -n "auth.admin_emails" ctp_blogr/*.inishows both files; the groupfinder receives['phuoc@caotek.fr']from settings; starting the app with the env vars unset raises a clear configuration error naming the missing variable, andgit grep -n "client_secret"shows only the interpolation, never a literal value. QA scenarios: happy - app boots with env vars set. failure - app refuses to boot with a readable message when they are absent. Evidence:<attemptDir>/task-4-google-oauth-migration.<ext>Commit: N - proposefeat(auth): add Google OAuth and admin email settings. -
5. Swap
AuthTktforSessionAuthenticationPolicywith a settings-based groupfinder What to do / Must NOT do: ReplaceAuthTktAuthenticationPolicy(and its hardcoded secret) with Pyramid'sSessionAuthenticationPolicy+SessionAuthenticationHelper, keepingroot_factory='ctp_blogr.security.RootFactory'andACLAuthorizationPolicysosecurity.pyis untouched. Rewrite thegroupfindercallback so it maps email -> group purely fromauth.admin_emailsin settings. MUST NOT query the database from the callback:pyramid_tmdefaultstm.annotate_usertoTrueand callsrequest.authenticated_useridinsidemanager.begin(), so a DB-backed groupfinder runs a SELECT on every request inside the transaction, and any DB error 500s every route including/loginand the callback itself. MUST NOT "fix" this by settingtm.annotate_user = false. Unknown email must returnNone, not raise. Parallelization: Wave 2 | Blocked by: 3, 4 | Blocks: 10, 11, 12 References (executor has NO interview context - be exhaustive):ctp_blogr/__init__.py:15(the policy block and secret);ctp_blogr/services/user.py:26(the currentuserid == 'admin'literal to remove);ctp_blogr/security.py; installedpyramid_tm/__init__.pyannotate_user = asbool(settings.get('tm.annotate_user', True))and therequest.authenticated_useridaccess insidemanager.begin();models/__init__.pysetssettings['tm.manager_hook'] = 'pyramid_tm.explicit_manager'. Acceptance criteria (agent-executable):groupfinder('phuoc@caotek.fr', request)returns['group:administrators'];groupfinder('ctphuoc@gmail.com', request)returns[];groupfinder('ghost@nowhere.test', request)returnsNone; no SQL is emitted during a groupfinder call (assert with an engine event listener);grep -rn "'admin'" ctp_blogr/returns nothing; the ACL file is byte-identical to its pre-change content. QA scenarios: happy - both known identities resolve to the expected principals and/usersis reachable only for the admin. failure -groupfinderon an unknown email returnsNoneand creates no row; a request with the database deliberately broken still serves/login(this is the B3 regression test). Evidence:<attemptDir>/task-5-google-oauth-migration.<ext>Commit: N - proposerefactor(auth): swap AuthTkt for SessionAuthenticationPolicy. -
6. Rebuild the
userstable and widenentries.creator/entries.editorWhat to do / Must NOT do: Against the backed-upctp_blogr.sqlite, rebuildusersto drop thepasswordcolumn (it isnullable=False, so a plainALTER TABLE ... DROP COLUMNis not the safe route), add a nullable uniquegoogle_sub, keepid/name(namestores the allowlisted email and staysNOT NULL+UNIQUE) andlast_logged. Set row 1nametophuoc@caotek.frand row 2 toctphuoc@gmail.com. Widenentries.creatorandentries.editorfromUnicode(50)toUnicode(255)in the same rebuild. Back up first (todo 2). MUST NOT introduce Alembic or amigrations/directory - none exists in this repo. MUST NOT touch thealembic_versionrow (revb8f8216d72c7), even though no Alembic is wired up. MUST NOT drop or alter theentriesrows themselves. Report clearly that migrations are missing. Parallelization: Wave 2 | Blocked by: 2 | Blocks: 9, 10, 14 References (executor has NO interview context - be exhaustive):ctp_blogr/models/user.py(columns,password nullable=False);ctp_blogr/models/blog_record.py(creator/editorUnicode(50));ctp_blogr/models/__init__.py:8-9(Tagsis defined inblog_recordbut not imported here - import it explicitly if mapper config is touched);ctp_blogr/views/blog.py:64,75(the unguarded writes);AGENTS.md"Database" section (the DB file is the schema source of truth;create_all()is test-only). Acceptance criteria (agent-executable):PRAGMA table_info(users)shows nopasswordcolumn,namestillNOT NULL+UNIQUE, and agoogle_subcolumn;SELECT COUNT(*) FROM users== 2 with exactlyphuoc@caotek.frandctphuoc@gmail.com;PRAGMA table_info(entries)showscreator/editorwidened;PRAGMA foreign_key_checkis clean;SELECT * FROM alembic_versionstill returnsb8f8216d72c7;SELECT COUNT(*) FROM entriesis unchanged from the pre-migration count;python -m pytest -qshows nono such table. QA scenarios: happy - all the assertions above pass against the real file. failure - run every assertion against the backup first to confirm the script is not silently a no-op, and confirm the restore path from todo 2 actually restores a working app. Evidence:<attemptDir>/task-6-google-oauth-migration.<ext>Commit: N - proposefeat(db): migrate users to Google identity and widen entry authors. The database diff is a real tracked change and must be inspected before any commit. -
7. Build the named Google OAuth collaborator with JWKS caching What to do / Must NOT do: Add a registry-registered service (e.g.
ctp_blogr/services/google_oauth.py) wrappingOAuth2Sessionfromauthlib.integrations.requests_client, configured from the discovery document soauthorize_url/access_token_url/jwks_uriare never hand-written. Expose exactly two operations:create_authorization_url(...)andfetch_claims(code, code_verifier, ...). The service MUST fetch and cache the discovery document and the JWKS itself with a bounded TTL, because the bare client does neither:CodeIDTokenplus key decoding needs the keys pulled fromjwks_urimanually, and refetching on every sign-in is a latency and rate-limit problem. Usejoserfc(Authlib's own forward dependency) to decode and verify theid_token, notauthlib.jose, which is soft-deprecated in 1.8.0 and warns. Sethdnowhere. MUST NOT useauthorize_redirect/save_authorize_data/set_state_data/clear_state_data- those are framework integrations that do not exist for Pyramid. Parallelization: Wave 2 | Blocked by: 1, 4 | Blocks: 8 References (executor has NO interview context - be exhaustive): discovery URLhttps://accounts.google.com/.well-known/openid-configuration; Authlib entry pointauthlib.integrations.requests_client.OAuth2Session;create_authorization_urlreturns the tuple(uri, state)and does not returncode_verifier, which the app generates withgenerate_token(48)and passes in itself; Google's OIDC reference (https://developers.google.com/identity/openid-connect/reference) requiresnonce;development.ini/production.inisettings from todo 4. Acceptance criteria (agent-executable): the service is reachable fromrequest.registry.google_oauth;create_authorization_urlreturns astateand an absoluteaccounts.google.comURI containingcode_challenge_method=S256, acode_challenge, anopenidscope and nohdparameter; two consecutivefetch_claimscalls hit the JWKS endpoint once (assert with a counting stub); the cache honours its TTL under a monkeypatched clock. QA scenarios: happy - authorization URL is well-formed and JWKS is fetched once. failure - a JWKS fetch failure surfaces a French error and never reaches the token endpoint; a cached-key path still validates a token after the TTL expires. Evidence:<attemptDir>/task-7-google-oauth-migration.<ext>Commit: N - proposefeat(auth): add Google OAuth client with state, PKCE and nonce. -
8. Add the authorize redirect and the callback views What to do / Must NOT do: Add a
/login/googleroute that generatesstate,nonceandcode_verifier, stores all three in the session, and redirects. Add the/login/google/callbackGET which: rejects?error=access_deniedwith a French flash and no DB write; consumes (deletes)statebefore attempting the exchange so a replay cannot reach the token endpoint; exchanges the code exactly once with the storedcode_verifierandredirect_uri; compares theid_tokennonceclaim against the stored value; deletesstate/code_verifier/nonce; and then defers to todo 9 for the allowlist gate andremember(). MUST NOT callrequest.session.renew()(it does not exist). MUST NOT letpyramid_retryreplay the exchange:pyramid_tmmarks exceptions retryable and a SQLite lock would re-run the callback intoinvalid_grant. Verify with a call-count assertion underretry.attempts=3. Parallelization: Wave 3 | Blocked by: 3, 7 | Blocks: 9, 13 References (executor has NO interview context - be exhaustive):ctp_blogr/routes.py(add both routes; note:5is a non-raw string emittingSyntaxWarningon 3.12+ - prefix withrif you edit that file, the new routes are purely additive);ctp_blogr/views/default.py:37-74(the existinglogin/logout/forgetpattern and itspermission/rendererconventions);development.ini(retry.attempts = 3); installedpyramid_tmretryable-error tagging. Acceptance criteria (agent-executable):statemismatch, missingstate, and replayedstateeach fail closed, call the collaborator zero times, and leave no session identity;noncemismatch is rejected with a stubbed mismatching claim;?error=access_deniedproduces a French flash, no DB write and no session identity; withretry.attempts=3and an injected transient error the collaborator is called exactly once; after a successful callback no session key matchesstate|code_verifier|nonce. QA scenarios: happy - full redirect-URI round trip with a stubbed token endpoint yields verified claims. failure - the six rejection cases above, each asserting zero token-endpoint calls. Evidence:<attemptDir>/task-8-google-oauth-migration.<ext>Commit: N - proposefeat(auth): add authorize and callback views. -
9. Gate sign-in on the database allowlist, then remember the identity What to do / Must NOT do: In the callback, after claims are verified, match the identity against the
userstable:google_subfirst, then the allowlisted email. A match updateslast_logged(the only writer, sinceverify_passwordis being deleted) and storesgoogle_subon first sign-in, then callsremember(email). No match is refused and no row is ever created. The refusal MUST setrequest.response.status_int = 403explicitly and render a French rejection template without raisingHTTPForbidden, becauseforbidden_view_configon login (views/default.py:38) catches that exception and renderslogin.jinja2with status 200 - a rejected user would otherwise be shown the dead password form. Replace thatforbidden_view_configwith a real 403 view rather than inheriting it. MUST NOT auto-provision. MUST NOT query the DB for the group mapping (todo 5). Parallelization: Wave 3 | Blocked by: 5, 6, 8 | Blocks: 13 References (executor has NO interview context - be exhaustive):ctp_blogr/views/default.py:38(theforbidden_view_configthat swallowsHTTPForbidden),:53-58(existingremember()/forget()call style),:68-74(logout);ctp_blogr/models/user.py:29(thelast_loggedwrite being removed withverify_password);templates/users.jinja2:27(last_logged.strftimewith no NULL guard); Google's production-readiness note that the test-user allowlist is bypassed for basic scopes, which is why this DB gate is mandatory rather than defence-in-depth. Acceptance criteria (agent-executable): unknown email -> status 403, body contains the French rejection message, body contains no<input type="password">, andSELECT COUNT(*) FROM usersis unchanged;phuoc@caotek.fryieldsauth.useridequal to that email plus themanageprincipal and a non-NULLlast_logged;ctphuoc@gmail.comyields a session withoutmanage; a second sign-in matches ongoogle_sub. QA scenarios: happy - both known identities sign in with the right principals. failure - unknown email refused at 403 with no row created; a tamperedsub/email pair refused. Evidence:<attemptDir>/task-9-google-oauth-migration.<ext>Commit: N - proposefeat(auth): gate sign-in on a database allowlist. -
10. Close the
/user_editauthorization hole and fix thedelete(...)NameErrorWhat to do / Must NOT do:views/default.py:88declarespermission='view', notmanage, and the only identity check in the function is== 'admin'for the return URL. Any authenticated user can therefore open/user_edit/<any-email>and rewrite that row, or/user_edit/0to create one. Change the permission tomanageand remove the create-user branch (:98-102,119-131) soname='0'is unreachable; if an admin-only allowlist editor is kept, it must requiremanageand must not accept a password. In the same change, fix:142delete(request, user.id)->UserService.delete(request, user.id); that method already exists atservices/user.py:18. Record that it is afilter().delete(synchronize_session=False)with no FK cascade fromentries.creator/entries.editor, so deleting a user strands PII in those columns. MUST NOT leave theNameErrorlive:user_edit.jinja2:45gates the delete button onrequest.authenticated_userid == 'admin', and swapping that literal for theeffective_principalscheck keeps the button rendering for admins. Parallelization: Wave 4 | Blocked by: 5, 6, 9 | Blocks: 13 References (executor has NO interview context - be exhaustive):ctp_blogr/views/default.py:88(permission),:93('admin'literal),:98-102and:119-131(create-user branch),:116-117(len(form.password.data) < 6),:127and:137(set_password),:142(theNameError);ctp_blogr/services/user.py:18(delete);ctp_blogr/templates/user_edit.jinja2:45(delete-button gate);ctp_blogr/templates/users.jinja2(theroute_url('user_edit', name='0')create link). Acceptance criteria (agent-executable): asctphuoc@gmail.com,GET /user_edit/phuoc@caotek.frandGET /user_edit/0are both refused and the response body does not contain the edit form;grep -c "route_url('user_edit', name='0')" ctp_blogr/templates/users.jinja2== 0; as admin, the delete button renders and submitting it deletes the row without raising; the other user row is untouched. QA scenarios: happy - admin edits and deletes correctly. failure - non-admin is refused on both URLs (content assertion, never a bare 200, because forbidden responses render the login page at 200 in this app). Evidence:<attemptDir>/task-10-google-oauth-migration.<ext>Commit: N - proposefix(auth): require manage on user_edit and fix user deletion. -
11. Replace the four literal
'admin'comparisons witheffective_principalsWhat to do / Must NOT do: Replace every authorization comparison against the literal'admin'with'group:administrators' in request.effective_principals:services/user.py:26,views/default.py:93,templates/layout.jinja2:43,templates/user_edit.jinja2:45. Templates cannot call a groupfinder, which is whyeffective_principalsis the correct template-side expression. Audit all 15request.authenticated_useridsites (7 Python, 8 templates) so none is left comparing an identity to a name. MUST NOT leave any== 'admin'behind. Parallelization: Wave 4 | Blocked by: 5, 9 | Blocks: 13 References (executor has NO interview context - be exhaustive):ctp_blogr/services/user.py:26;ctp_blogr/views/default.py:93;ctp_blogr/templates/layout.jinja2:43and:46;ctp_blogr/templates/user_edit.jinja2:45;ctp_blogr/services/blog_record.pyx3,ctp_blogr/views/blog.py:64,75, x2,ctp_blogr/views/default.pyx2,templates/layout.jinja2x4,templates/blog.jinja2x2,templates/home.jinja2x1,templates/user_edit.jinja2x1. Acceptance criteria (agent-executable):grep -rn "'admin'" ctp_blogr/returns no authorization comparison; asphuoc@caotek.frthe admin navigation item renders and/usersis reachable; asctphuoc@gmail.comit does not render and/usersdoes not containusers_list. QA scenarios: happy - both identities see exactly the navigation they should. failure - the non-admin sees no admin-only control in any of the 15 audited sites. Evidence:<attemptDir>/task-11-google-oauth-migration.<ext>Commit: N - proposerefactor(auth): replace literal admin checks with effective_principals. -
12. Remove the password surface and fix every template it breaks What to do / Must NOT do: Remove the
password/confirmWTForms fields and thelen(form.password.data) < 6check,User.verify_password/set_password, the top-levelimport passlibatmodels/user.py:11, and thepasslibdependency. Fix every template that renders a removed field, because Jinja2's defaultUndefinedraises on attribute access:user_edit.jinja2:27-28,36-37. Makeusers.jinja2:27NULL-safe forlast_logged. Remove the non-admin self-service password path and the admin "Nouvel utilisateur" button. MUST NOT removeimport passliblast - leaving it breaks the entire app at import time, since the app will not even boot. MUST NOT touch the deadHistoForm/AllocationForm/ActifForm/Actif2Formforms or their orphanedhisto/allocation/actifstables. Parallelization: Wave 4 | Blocked by: 6, 10, 11 | Blocks: 13 References (executor has NO interview context - be exhaustive):ctp_blogr/forms.py(password / confirm fields; plus the dead forms to ignore);ctp_blogr/models/user.py:11(import) and:29(last_loggedwriter);ctp_blogr/views/default.py:116-117,127,137,98-102,119-131;ctp_blogr/templates/user_edit.jinja2:27-28,36-37;ctp_blogr/templates/users.jinja2:27;ctp_blogr/templates/login.jinja2(replaced by a single "Se connecter avec Google" control);setup.py(thepasslibrequirement). Acceptance criteria (agent-executable):grep -rn passlib ctp_blogr/returns nothing andpython -c "from pyramid.paster import get_app; get_app('development.ini')"still exits 0 (the import-order trap);GET /usersreturns 200 for a row whoselast_loggedis NULL;GET /user_edit/<email>returns 200 with noname="password"in the HTML;grep -rn "form.password" ctp_blogr/returns nothing. QA scenarios: happy -/usersand/user_edit/<email>render clean with no password markup. failure - the NULL-last_loggedrow renders 200; the app boots withpasslibuninstalled from the source tree. Evidence:<attemptDir>/task-12-google-oauth-migration.<ext>Commit: N - proposerefactor(auth): remove password forms and password storage. -
13. Build a new WSGI test harness and the full offline suite What to do / Must NOT do: Build a new WSGI test app - the existing
BaseTestconstructs atesting.DummyRequest, so WebTest cannot drive it, andBaseTest.init_database()is defined but never called, so no table is ever created. The harness must create a real router onsqlite:///:memory:withBase.metadata.create_all(), register the stubbed Google collaborator from todo 7, and disablepyramid_retry. Cover the happy path and every failure path in the Acceptance criteria above: unknown email, state mismatch / missing / replay, nonce mismatch,error=access_denied, retry-safety call count, the/user_editand/usersauthorization refusals,POST /blog_editnot mutating for a non-admin, the NULL-last_loggedrender, and the two legacy duplicate tests. MUST NOT perform network I/O. MUST NOT assert a bare 200 as proof of access. Decide explicitly whether the two legacy duplicate tests are kept or deliberately deleted. Parallelization: Wave 5 | Blocked by: 1-12 | Blocks: F1-F4 References (executor has NO interview context - be exhaustive):ctp_blogr/tests.py(BaseTest,DummyRequest, the uncalledinit_database, the two duplicate tests);pyramid.testing.DummyRequestfor the lighter alternative;ctp_blogr/models/__init__.py; the "Testing" section ofAGENTS.md(pytest.ini setstestpaths = ctp_blograndpython_files = test*.py, so a new file must be namedtest*.pyinside the package). Acceptance criteria (agent-executable):python -m pytest -qexits 0 with0 failedand an exact reported count, nono such tableerrors, and no outbound socket (assert by blockingsocket.socketin a fixture); criteria 1-22 from Verification all pass. QA scenarios: happy - the complete sign-in flow passes end to end offline. failure - each of the ten failure criteria fails closed, most importantly that a non-admin cannot reach/user_edit/<other-email>and that a replayedstatenever reaches the token endpoint. Evidence:<attemptDir>/task-13-google-oauth-migration.<ext>Commit: N - proposetest(auth): cover the Google OAuth flow without network access. -
14. Update
AGENTS.mdso it still describes reality What to do / Must NOT do: Rewrite the "Architecture / wiring" section for the new policy and the settings-based admin list, and correct "Known-broken" for the items this migration resolves: thedelete(...)NameError, the 200-not-403 forbidden behaviour and itsforbidden_view_configcause, and the auth-ticket secret. Remove the now-false statement that admin is a hardcodeduserid == 'admin'. MUST NOT invent linting, CI or a task runner, and MUST NOT claim any command was run that was not. Note that coverage is still broken and still must not be trusted. Parallelization: Wave 5 | Blocked by: 13 | Blocks: F1-F4 References (executor has NO interview context - be exhaustive):AGENTS.md"Architecture / wiring", "Known-broken", "Conventions" and "Testing" sections. Acceptance criteria (agent-executable):grep -n "AuthTkt" AGENTS.mdreturns nothing;grep -n "userid == 'admin'" AGENTS.mdreturns nothing; every command listed inAGENTS.md"Commands" still runs as written. QA scenarios: happy - the documented wiring matches the code. failure - a reviewer greps eachAGENTS.mdclaim and confirms it against the source. Evidence:<attemptDir>/task-14-google-oauth-migration.<ext>Commit: N - proposedocs: update AGENTS.md for the Google OAuth migration.
Final verification wave
Runs in parallel after ALL todos. ALL must APPROVE. Surface results and wait for the user's explicit okay before declaring complete.
- F1. Plan compliance audit - confirm every todo's acceptance criteria and all 22 criteria in Verification were actually executed, with evidence files present.
- F2. Code quality review - re-read the diff for the failure modes the gap analysis surfaced:
retry replay, stale session values, the
passlibimport order,UndefinedErrorin templates, and any remaining literal identity comparison. - F3. Real manual QA - boot
pserve development.ini, complete a real Google sign-in end to end in a browser, and smoke all 15 routes asserting content rather than bare 200. Requires the Google Cloud OAuth client and registered redirect URI from the Verification prerequisite; if the user has not provisioned them, report F3 as BLOCKED-ON-USER rather than APPROVE or REJECT. - F4. Scope fidelity - confirm nothing outside Scope changed: no
pyramid_csrf, nopyramid_beaker, no Alembic, nohd, no offline access, no auto sign-up, no new tooling, and no change to the out-of-scope items (post id 95,Query.get(),routes.py:5,declarative_base, the dead forms, the draft-leak filters).
Commit strategy
The pre-existing history is two commits on master - 411ca8d "Inital commit since swicthing to python 3.14" followed by ea830da "opencode /init done"; main and origin/* point at the same
tip and the branch choice is unsettled. Per todo 2, an explicit branch decision is a
prerequisite and no branch is created, no push is made, and nothing is committed unless the user
asks - AGENTS.md states "Commit only when asked".
Suggested messages, matching the repo's terse existing style:
feat(deps): add authlib for Google OAuthfix(auth): harden and configure the signed session cookiefeat(auth): add Google OAuth and admin email settingsrefactor(auth): swap AuthTkt for SessionAuthenticationPolicyfeat(db): migrate users to Google identity and widen entry authorsfeat(auth): add Google OAuth client with state, PKCE and noncefeat(auth): add authorize and callback viewsfeat(auth): gate sign-in on a database allowlistfix(auth): require manage on user_edit and fix user deletionrefactor(auth): replace literal admin checks with effective_principalsrefactor(auth): remove password forms and password storagetest(auth): cover the Google OAuth flow without network accessdocs: update AGENTS.md for the Google OAuth migration
Restore path for ctp_blogr.sqlite (established in todo 2, required before todo 6):
- Branch:
feat/google-oauth, created frommasterat3dcb88e. No commits, no pushes. - Verified backup (outside the working tree):
/var/folders/cm/x5cx1bfd749gxd1x_zt6bvp40000gn/T/opencode/ctp_blogr-backup-20261005-202240.sqlite—PRAGMA integrity_checkreturnsok. - Restore the pre-migration database:
cp /var/folders/cm/x5cx1bfd749gxd1x_zt6bvp40000gn/T/opencode/ctp_blogr-backup-20261005-202240.sqlite ctp_blogr.sqlite - Restore the committed database:
git checkout -- ctp_blogr.sqlite - Abort the whole migration:
git checkout master
The database diff is a real tracked change and must be inspected before any commit.
Success criteria
- Signing in with
phuoc@caotek.fryields a session whoseauthenticated_useridis that email and which carries thegroup:administratorsprincipal; the/userspage is reachable. - Signing in with
ctphuoc@gmail.comyields a session WITHOUTmanage;/usersdoes not expose the user list. - Signing in with any other Google account produces an explicit 403 with a French message and no password input on the page, creates no database row, and makes no group lookup.
- The password column, passlib, the password form fields, and the local login form are gone from the codebase; no route, template or form field still references them, and the app still boots.
- No source line compares an identity to the literal
'admin'; the admin mapping is theauth.admin_emailssetting. - No session key matching
state|code_verifier|noncesurvives a completed sign-in. - A replayed or mismatched
state, and a mismatchednonce, each reach the token endpoint zero times and leave no session identity. python -m pytestpasses with the new tests, no test performs network I/O, and no test asserts a bare 200 as proof of access.pserve development.iniserves/, and/loginpresents a single "Se connecter avec Google" control instead of a credential form.AGENTS.mdis updated: its "Architecture / wiring" and "Known-broken" sections still describe reality after the migration (it currently documents the AuthTkt + groupfinder wiring, the 200-not-403 behaviour, and thedelete(...)NameError that this plan fixes).