ธีม
Archived from STATE.md 2026-07-31 — the GAS + Drive migration, in full
Kept because the sequencing is the reusable part. Summary lives in STATE.md; the bug classes are in .claude/rules/mistakes.md.
APPS SCRIPT — automated deploys (new)
npm run deploy:gas (tools/deploy-gas.mjs). Live state: script 179DfoS1…, deployment AKfycbw1iHE4… @51, /exec URL unchanged.
- Setup already done on this Mac:
npx clasp login(asmdstuddata.beta@gmail.com), Apps Script API enabled,GAS_SCRIPT_IDin.env.local. - Never runs
clasp deploy— that mints a NEW deployment with a NEW/execURL whileGAS_API_URLstays hard-coded, which presents as "every upload silently fails". It doescreate-version+update-deploymenton the same id. - The deployment id is derived from
GAS_API_URLinsrc/js/config.js(its path segment IS the id). That matters: this script has THREE deployments (one@HEAD, the live web app, an old@25kept for rollback), so "pick the only non-HEAD one" is ambiguous. - Diffs the remote before overwriting and refuses if the remote has lines the repo doesn't (someone edited in the browser);
--forceoverrides,--dry-runreports only,--verifyprobes the live endpoint. - Canary:
POST {action:'uploadTeamFile'}with nofolderPath→ the handler validates before touching Drive, so it proves the new code is serving while writing nothing.folderPath is required= new,Unknown action= old. - Rollback:
cd .gas-build && npx clasp update-deployment AKfycbw1iHE4… -V 50.
Drive layout: My Drive / IT Database + canonical names (@49, DONE)
My Drive/IT Database/
├── PR/ (was PR_Submissions, id 19eMp-bjx7…, 200 children)
├── Projects/ (id 1_Gm-XvN…, 22 children)
├── Shop/ (was SAMO_Shop)
├── Team/ (was SAMO_Team)
└── Passport/ ← badges/ + certificates/, written by the PASSPORT scriptThe SAMO_ prefixes existed to namespace folders sitting loose in My Drive root; the container does that now, so they were dropped and the casing made uniform. Verified live: every folder kept its original id — the resolver only moves/renames, so no stored URL changed and nothing was backfilled.
- Shipped expand-then-contract, GAS first. @49 teaches
TOP_FOLDER_CANON(both the rename map AND the transition allow-list — either spelling resolves), then the frontend switched to the canonical names. The reverse order would have failed every upload on the allow-list. Legacy keys stay until no deployed bundle can send them. fileLivesUnderTop_is the trap to remember: the ancestry guards match BY NAME and gate DELETION, so a rename would have silently made every slip/file delete refuse. They canonicalise through the same map now.migrateDriveLayout/inspectDriveLayoutare editor-only (nodoPostroute); they only move/rename, verify child counts before+after, REFUSE a split, and never create a folder that doesn't exist.- DONE — passport now has clasp tooling too.
passport/tools/deploy-gas.mjs(port of this repo's; readsVITE_GAS_UPLOAD_URL, inert{action:'ping'}canary, needsGAS_SCRIPT_IDin that repo's.env.local).gas/Upload.gsdeployed as v5, live-verified{"ok":true,"layout":"IT Database/Passport"}. - PENDING (one click, no urgency):
badges/certificatesare still at My Drive root. Run ▸migrateDriveLayoutin thesamopassporteditor to move them now, or leave it — the next real badge upload adopts them automatically. - DONE: the Apps Script projects are Drive files too, and now live in
IT Database/_Scripts/—prformweb(the Sheet this script is bound to) andsamopassport.Uploadbadgesamopassport(dead prototype) is trashed, recoverable ~30 days. Done by a run-oncetidyScriptFiles()in @50, removed again in @51. Moving the container Sheet did NOT disturb the deployment — proven by the @51 deploy + live probe afterwards. - PENDING (setup, yours):
clasp run— seeskills/deploy-gas.md. Needs a standard GCP project attached to the script, a Desktop OAuth client, and an API Executable deployment. That extra deployment does NOT touch the web app/execURL (different entry-point type) but does makelist-deploymentsshow four. Bounded by discipline, not technically: thescripts.runtoken carries the script's Drive scope.
DONE — prformweb → standalone samoweb (code side complete)
Why: a container-bound script is stored INSIDE its container, so trashing the unused prformweb spreadsheet would have taken the script AND every deployment with it — killing PR/shop/projects/team uploads and the projects email at once, with no obvious cause. That is a single point of failure behind a file that looks like junk.
New STANDALONE script
samoweb=1lENmMdToG_PTrIo1ytJbalhN5EviIiVuAU8o3yiOQlgvGJN6tcFDCVVpCode verified byte-identical toappscript/prform.gs; manifest copied verbatim from the old project soexecuteAs/accesscould not drift.Deployment
AKfycbwomKii…@1, owner-authorized, live-verified.src/js/config.jsnow points at it; built bundle carries the new URL.The OLD script + deployment are deliberately STILL LIVE so bundles cached before this change keep working. Both run identical code under the same account and resolve the SAME Drive folders, so the overlap is behaviourally indistinguishable and NO data moves.
DEPLOYED to the VM 2026-07-31. Verified in the SERVED bundle:
/assets/analytics-BpK2gflv.jscarries the NEW deployment id and zero occurrences of the old one, from both/and/admin/..env.localGAS_SCRIPT_IDrepointed, sonpm run deploy:gasnow targetssamoweb; a--dry-runconfirms remote == repo.Old vs new endpoints proven behaviourally identical: 6/6 probes match (validation, unknown action, legacy folder name, wrong-tree rejection, root-ref rejection, path traversal).
ONLY REMAINING — in ~2 weeks, once the old endpoint sees no traffic: delete deployment AKfycbw1iHE4…, then prformweb and its Sheet. Nothing depends on them; they exist purely so bundles cached before this deploy keep working.
Rollback: revert the one line in config.js and redeploy. The old endpoint has not been touched.
Drive layout — FINAL, verified
My Drive/IT Database/
├── _Scripts/ prformweb [sheet], samopassport [script], samoweb [script]
├── PR/ 301 children (was PR_Submissions)
├── Projects/ 22 โครงการ
├── Shop/ Banners, Products, QR, Slips
├── Team/ 2569/
└── Passport/ badges/ 15, certificates/ 10Nothing of ours is left in My Drive root. Every folder kept its original id through the move+rename, so no stored URL changed and nothing was backfilled.
GAS security review 2026-07-31 — two live holes closed, one accepted
Prompted by "scan for every bug thoroughly". Both were years old and neither was introduced by the Drive move; both are in .claude/rules/mistakes.md.
- CLOSED — passport
handleDelete_trashed ANY fileId. Anonymous endpoint,/execURL public in the shipped admin bundle ⇒ unauthenticated "trash any file this account owns". Now requires the file to live under the app's own folder, matched by folder ID so a rename can't widen or break it. Verified both ways: a real file outsidePassport/survives the attack, and upload+delete of a badge still works (count back to 15). - CLOSED — samoweb
notifyProjectEmailwas an OPEN RELAY. Arbitrary recipient/subject/body from the SAMO account as "MDKKU SAMO". Now a domain allow-list (kku.ac.th,kkumail.com), overridable via theEMAIL_DOMAIN_ALLOWLISTscript property. EVERY recipient checked, exact match. Verified: gmail /kku.ac.th.evil.com/notkku.ac.th/ a smuggled second recipient all rejected; a realkku.ac.thstill sends. Live recipientworatho@kku.ac.thunaffected. - IN PROGRESS — caller identity on the destructive actions. Scope alone is not authorization; being closed the same day (see below). This repo is PUBLIC: never write still-open vulnerability detail into it.
- PERF:
warnIfSplit_was 3 Drive round-trips on every upload (~half of folder-resolution time). Clean results cached 6h, dirty never cached. Resolution ~2.7s cold → ~0.7s warm.
The three Apps Script projects (they are NOT one)
| project | type | serves | live |
|---|---|---|---|
prformweb | bound to a Google Sheet in root | samoweb Drive + email | AKfycbw1iHE4… @49 |
samopassport | standalone | passport badges/certs (gas/Upload.gs) | AKfycbwJgkPTcr9G… @v3 |
Uploadbadgesamopassport | standalone | nothing — dead prototype | orphan, to trash |
prform.gs has ZERO SpreadsheetApp references — the Sheet binding is vestigial, from when the PR form wrote rows. It cannot be unbound: converting to standalone mints a new project and a new /exec URL, and GAS_API_URL is hard-coded, so it would present as "every upload silently fails". Move the Sheet, don't try to detach it.
Deliberately NOT merged with the passport script: one URL would have to change (in the repo with no GAS tooling), the blast radius would couple, and prform's MailApp scope would extend to passport uploads. Quotas are per ACCOUNT, not per script, so there is no quota argument either. If it is ever merged, fold passport INTO prform — prform has the good pipeline.