github-actions[bot] commited on
Commit
7443b93
·
1 Parent(s): 0bdb418

Deploy df060e3

Browse files

The construct is the noun; the tools are verbs on it

Source: https://github.com/WINTER4000/turingDNA/commit/df060e3d534f6dad3fd12bf6cc4e4820f3d0da68

dee/auth.py CHANGED
@@ -3026,3 +3026,261 @@ def edit_memory(user_id: Optional[str], memory_id: str, fact: str) -> Dict[str,
3026
  def forget_memory(user_id: Optional[str], memory_id: str) -> Dict[str, Any]:
3027
  """Delete outright. Memory the user cannot remove isn't theirs."""
3028
  return _memory_write(user_id or "", memory_id, "DELETE")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3026
  def forget_memory(user_id: Optional[str], memory_id: str) -> Dict[str, Any]:
3027
  """Delete outright. Memory the user cannot remove isn't theirs."""
3028
  return _memory_write(user_id or "", memory_id, "DELETE")
3029
+
3030
+
3031
+ # ═══════════════════════════════════════════════════════════════════════════
3032
+ # Constructs — the thing every tool operates ON
3033
+ # ═══════════════════════════════════════════════════════════════════════════
3034
+ # Migration 0012 created this table for exactly this purpose and nothing ever
3035
+ # read or wrote it. Meanwhile /api/mission reconstructed "constructs" on every
3036
+ # request by grouping artifacts on a NORMALISED NAME STRING — which merges
3037
+ # every artifact left at its default name ("Plasmid", "Library", …) into one
3038
+ # card, and splits a construct in two the moment you rename something.
3039
+ #
3040
+ # The point of wiring it now is not storage. It is that once a construct is
3041
+ # the thing the user has selected, every tool call can carry its id, and the
3042
+ # question "what work belongs together?" stops being a guess reconstructed
3043
+ # afterwards and becomes something recorded at the moment it happened.
3044
+ #
3045
+ # NOTE on the *_ids arrays: they are a bag, not a graph — they say a construct
3046
+ # HAS these artifacts, never that one was derived from another. Real lineage
3047
+ # (which region of which plasmid produced which guide set) needs its own edge
3048
+ # table; this is the grouping layer beneath it, not a substitute for it.
3049
+
3050
+ _CONSTRUCTS_LIST_LIMIT = 100
3051
+ _CONSTRUCT_SEQ_MAX = 400_000
3052
+ _CONSTRUCT_ARTIFACT_KINDS = {"plasmid": "plasmid_ids",
3053
+ "crispr": "crispr_ids",
3054
+ "primer": "primer_ids"}
3055
+ _CONSTRUCT_PHASES = ("Design", "Build", "Edit", "Learn")
3056
+
3057
+
3058
+ def _valid_uuid(value: str) -> bool:
3059
+ import re as _re
3060
+ return bool(_re.match(r"^[0-9a-fA-F-]{36}$", value or ""))
3061
+
3062
+
3063
+ def save_construct(
3064
+ user_id: Optional[str], *,
3065
+ name: str, sequence_hash: str, wt_identifier: str = "",
3066
+ wt_protein: str = "", sequence_dna: str = "", phase: str = "Design",
3067
+ meta: Optional[Dict[str, Any]] = None,
3068
+ ) -> Dict[str, Any]:
3069
+ """Create a construct. Returns {ok, id} or {ok:False, error}.
3070
+
3071
+ Callers should prefer :func:`find_or_create_construct` — a user pasting the
3072
+ same sequence twice means "the thing I am already working on", not a second
3073
+ project, and silently creating duplicates is how a workspace becomes a mess
3074
+ the user has to tidy.
3075
+ """
3076
+ if not user_id:
3077
+ return {"ok": False, "error": "Sign in to keep projects."}
3078
+ if not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
3079
+ return {"ok": False, "error": "Projects aren't configured on this server."}
3080
+ if not sequence_hash:
3081
+ return {"ok": False, "error": "A construct needs a sequence to group on."}
3082
+ from datetime import datetime, timezone, timedelta
3083
+ expires_at = (None if has_pro_plan(user_id) else
3084
+ (datetime.now(timezone.utc)
3085
+ + timedelta(days=FREE_LIBRARY_TTL_DAYS)).isoformat())
3086
+ payload = {
3087
+ "user_id": user_id,
3088
+ "name": (name or "Untitled construct")[:120],
3089
+ "sequence_hash": sequence_hash[:64],
3090
+ "wt_identifier": (wt_identifier or "")[:200] or None,
3091
+ "wt_protein": (wt_protein or "")[:_CONSTRUCT_SEQ_MAX],
3092
+ "sequence_dna": (sequence_dna or "")[:_CONSTRUCT_SEQ_MAX],
3093
+ "phase": phase if phase in _CONSTRUCT_PHASES else "Design",
3094
+ "meta": meta or {},
3095
+ "expires_at": expires_at,
3096
+ }
3097
+ import urllib.request
3098
+ import urllib.error
3099
+ try:
3100
+ req = urllib.request.Request(
3101
+ f"{SUPABASE_URL}/rest/v1/constructs",
3102
+ data=json.dumps(payload).encode("utf-8"),
3103
+ method="POST",
3104
+ headers=_user_pgrst_headers({"Content-Type": "application/json",
3105
+ "Prefer": "return=representation"}),
3106
+ )
3107
+ with urllib.request.urlopen(req, timeout=8.0) as resp:
3108
+ rows = json.loads(resp.read().decode("utf-8"))
3109
+ row = rows[0] if isinstance(rows, list) and rows else {}
3110
+ return {"ok": True, "id": row.get("id"), "construct": row}
3111
+ except urllib.error.HTTPError as exc:
3112
+ body = ""
3113
+ try:
3114
+ body = exc.read().decode("utf-8")[:200]
3115
+ except Exception: # noqa: BLE001
3116
+ pass
3117
+ logger.warning("constructs insert HTTP %s: %s", exc.code, body)
3118
+ if exc.code in (404, 406) or "PGRST" in body:
3119
+ return {"ok": False,
3120
+ "error": "Projects aren't enabled yet (database migration pending)."}
3121
+ return {"ok": False, "error": "Couldn't create the project."}
3122
+ except Exception as exc: # noqa: BLE001
3123
+ logger.warning("constructs insert failed: %s", exc)
3124
+ return {"ok": False, "error": "Couldn't create the project."}
3125
+
3126
+
3127
+ def list_constructs(user_id: Optional[str]) -> list:
3128
+ """The user's constructs, newest-touched first. Metadata only — the DNA
3129
+ and protein are deliberately excluded so the switcher stays small."""
3130
+ if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
3131
+ return []
3132
+ import urllib.request
3133
+ try:
3134
+ url = (f"{SUPABASE_URL}/rest/v1/constructs"
3135
+ f"?user_id=eq.{user_id}"
3136
+ f"&select=id,name,created_at,updated_at,phase,sequence_hash,"
3137
+ f"wt_identifier,library_id,plasmid_ids,crispr_ids,primer_ids"
3138
+ f"&order=updated_at.desc&limit={_CONSTRUCTS_LIST_LIMIT}")
3139
+ req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers())
3140
+ with urllib.request.urlopen(req, timeout=6.0) as resp:
3141
+ return json.loads(resp.read().decode("utf-8")) or []
3142
+ except Exception as exc: # noqa: BLE001
3143
+ logger.warning("constructs list failed for %s: %s", user_id, exc)
3144
+ return []
3145
+
3146
+
3147
+ def get_construct(user_id: Optional[str], construct_id: str) -> Optional[Dict[str, Any]]:
3148
+ """One construct in full, ownership enforced."""
3149
+ if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
3150
+ return None
3151
+ if not _valid_uuid(construct_id):
3152
+ return None
3153
+ import urllib.request
3154
+ try:
3155
+ url = (f"{SUPABASE_URL}/rest/v1/constructs"
3156
+ f"?id=eq.{construct_id}&user_id=eq.{user_id}&select=*&limit=1")
3157
+ req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers())
3158
+ with urllib.request.urlopen(req, timeout=6.0) as resp:
3159
+ rows = json.loads(resp.read().decode("utf-8"))
3160
+ return rows[0] if isinstance(rows, list) and rows else None
3161
+ except Exception as exc: # noqa: BLE001
3162
+ logger.warning("constructs get failed: %s", exc)
3163
+ return None
3164
+
3165
+
3166
+ def find_construct_by_hash(user_id: Optional[str], sequence_hash: str
3167
+ ) -> Optional[Dict[str, Any]]:
3168
+ """The construct already grouping this sequence, if any.
3169
+
3170
+ This is the whole reason `sequence_hash` is on the table: the same sequence
3171
+ means the same piece of work, regardless of what the user called it. It is
3172
+ the honest version of the name-string matching /api/mission does today.
3173
+ """
3174
+ if not user_id or not sequence_hash or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
3175
+ return None
3176
+ import urllib.request
3177
+ import urllib.parse
3178
+ try:
3179
+ url = (f"{SUPABASE_URL}/rest/v1/constructs"
3180
+ f"?user_id=eq.{user_id}"
3181
+ f"&sequence_hash=eq.{urllib.parse.quote(sequence_hash[:64])}"
3182
+ f"&select=*&order=updated_at.desc&limit=1")
3183
+ req = urllib.request.Request(url, method="GET", headers=_user_pgrst_headers())
3184
+ with urllib.request.urlopen(req, timeout=6.0) as resp:
3185
+ rows = json.loads(resp.read().decode("utf-8"))
3186
+ return rows[0] if isinstance(rows, list) and rows else None
3187
+ except Exception as exc: # noqa: BLE001
3188
+ logger.warning("constructs find-by-hash failed: %s", exc)
3189
+ return None
3190
+
3191
+
3192
+ def update_construct(user_id: Optional[str], construct_id: str,
3193
+ **fields: Any) -> Dict[str, Any]:
3194
+ """Patch a construct. Only a fixed set of fields is writable, so a stray
3195
+ key from a request body can never reach the row."""
3196
+ if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
3197
+ return {"ok": False, "error": "Not configured."}
3198
+ if not _valid_uuid(construct_id):
3199
+ return {"ok": False, "error": "Bad id."}
3200
+ allowed = {"name", "phase", "library_id", "plasmid_ids", "crispr_ids",
3201
+ "primer_ids", "meta", "wt_protein", "sequence_dna",
3202
+ "wt_identifier"}
3203
+ payload = {k: v for k, v in fields.items() if k in allowed and v is not None}
3204
+ if "name" in payload:
3205
+ payload["name"] = str(payload["name"])[:120] or "Untitled construct"
3206
+ if "phase" in payload and payload["phase"] not in _CONSTRUCT_PHASES:
3207
+ payload.pop("phase")
3208
+ if not payload:
3209
+ return {"ok": False, "error": "Nothing to update."}
3210
+ from datetime import datetime, timezone
3211
+ payload["updated_at"] = datetime.now(timezone.utc).isoformat()
3212
+ import urllib.request
3213
+ try:
3214
+ url = (f"{SUPABASE_URL}/rest/v1/constructs"
3215
+ f"?id=eq.{construct_id}&user_id=eq.{user_id}")
3216
+ req = urllib.request.Request(
3217
+ url, data=json.dumps(payload).encode("utf-8"), method="PATCH",
3218
+ headers=_user_pgrst_headers({"Content-Type": "application/json",
3219
+ "Prefer": "return=representation"}))
3220
+ with urllib.request.urlopen(req, timeout=6.0) as resp:
3221
+ rows = json.loads(resp.read().decode("utf-8"))
3222
+ return {"ok": True,
3223
+ "construct": rows[0] if isinstance(rows, list) and rows else None}
3224
+ except Exception as exc: # noqa: BLE001
3225
+ logger.warning("constructs update failed: %s", exc)
3226
+ return {"ok": False, "error": "Couldn't update the project."}
3227
+
3228
+
3229
+ def delete_construct(user_id: Optional[str], construct_id: str) -> Dict[str, Any]:
3230
+ """Delete the grouping only. The artifacts it referenced are NOT touched:
3231
+ deleting a project must never silently destroy a plasmid the user still
3232
+ has open in another tab."""
3233
+ if not user_id or not (SUPABASE_URL and SUPABASE_SERVICE_KEY):
3234
+ return {"ok": False, "error": "Not configured."}
3235
+ if not _valid_uuid(construct_id):
3236
+ return {"ok": False, "error": "Bad id."}
3237
+ import urllib.request
3238
+ try:
3239
+ url = (f"{SUPABASE_URL}/rest/v1/constructs"
3240
+ f"?id=eq.{construct_id}&user_id=eq.{user_id}")
3241
+ req = urllib.request.Request(
3242
+ url, method="DELETE",
3243
+ headers=_user_pgrst_headers({"Prefer": "return=minimal"}))
3244
+ with urllib.request.urlopen(req, timeout=6.0):
3245
+ return {"ok": True}
3246
+ except Exception as exc: # noqa: BLE001
3247
+ logger.warning("constructs delete failed: %s", exc)
3248
+ return {"ok": False, "error": "Couldn't delete."}
3249
+
3250
+
3251
+ def attach_artifact(user_id: Optional[str], construct_id: str,
3252
+ kind: str, artifact_id: str) -> Dict[str, Any]:
3253
+ """Record that an artifact belongs to a construct, and advance the phase.
3254
+
3255
+ Read-modify-write rather than an atomic array append, because PostgREST
3256
+ has no append primitive. Two tool runs finishing in the same instant could
3257
+ drop one id — acceptable for a single user's own work, and the loss is a
3258
+ missing cross-reference rather than a lost artifact, which still exists in
3259
+ its own table. Worth replacing when the lineage edge table lands, since
3260
+ that wants insert-only rows anyway.
3261
+
3262
+ Never raises: attribution failing must not fail the tool run the user
3263
+ actually asked for.
3264
+ """
3265
+ col = _CONSTRUCT_ARTIFACT_KINDS.get(kind)
3266
+ if not user_id or not col or not _valid_uuid(construct_id) \
3267
+ or not _valid_uuid(artifact_id):
3268
+ return {"ok": False, "error": "Bad attach request."}
3269
+ row = get_construct(user_id, construct_id)
3270
+ if not row:
3271
+ return {"ok": False, "error": "No such project."}
3272
+ current = row.get(col) or []
3273
+ if artifact_id in current:
3274
+ return {"ok": True, "already": True}
3275
+ updated = (current + [artifact_id])[-200:]
3276
+ # Phase only ever moves forward. A user opening the CRISPR tool after
3277
+ # logging results should not drag the project back from Learn to Edit.
3278
+ phase_for = {"plasmid": "Build", "primer": "Build", "crispr": "Edit"}
3279
+ want = phase_for.get(kind)
3280
+ fields: Dict[str, Any] = {col: updated}
3281
+ if want:
3282
+ cur_i = _CONSTRUCT_PHASES.index(row.get("phase") or "Design") \
3283
+ if (row.get("phase") in _CONSTRUCT_PHASES) else 0
3284
+ if _CONSTRUCT_PHASES.index(want) > cur_i:
3285
+ fields["phase"] = want
3286
+ return update_construct(user_id, construct_id, **fields)
dee/server.py CHANGED
@@ -826,6 +826,8 @@ _RL_RULES = [
826
  ("/api/dna/generate", (6, 60)), # autoregressive — priciest
827
  ("/api/compiler", (30, 60)), # pure logic; probes DNA reach
828
  ("/api/dna", (12, 60)),
 
 
829
  ("/api/plasmid", (60, 60)),
830
  ("/api/ping", (60, 60)), # dwell heartbeat — its own
831
  # bucket so ~2/min never eats
@@ -2198,6 +2200,25 @@ def create_app() -> Flask:
2198
  return jsonify({"error": "Alignment failed — check the two sequences."}), 500
2199
  return jsonify({"ok": True, **result})
2200
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2201
  @app.post("/api/plasmid/save")
2202
  def plasmid_save() -> Response:
2203
  auth, denied = _plasmid_signin_guard()
@@ -2218,6 +2239,7 @@ def create_app() -> Flask:
2218
  source=str(body.get("source", "") or "")[:16] or None,
2219
  sequence=data["sequence"], features=data["features"],
2220
  )
 
2221
  _auth.cleanup_expired_plasmids_async(auth.user_id)
2222
  return jsonify(result), (200 if result.get("ok") else 502)
2223
 
@@ -2761,6 +2783,7 @@ def create_app() -> Flask:
2761
  input_length=input_length,
2762
  guides=guides,
2763
  )
 
2764
  _auth.cleanup_expired_crispr_designs_async(auth.user_id) # lazy sweep
2765
  return jsonify(result), (200 if result.get("ok") else 502)
2766
 
@@ -2815,6 +2838,7 @@ def create_app() -> Flask:
2815
  n_primers=n_primers,
2816
  result=result_obj,
2817
  )
 
2818
  _auth.cleanup_expired_primer_analyses_async(auth.user_id) # lazy sweep
2819
  return jsonify(result), (200 if result.get("ok") else 502)
2820
 
@@ -3646,6 +3670,123 @@ def create_app() -> Flask:
3646
  data["ok"] = True
3647
  return _public_cors(jsonify(data))
3648
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3649
  @app.get("/api/mission")
3650
  def mission() -> Response:
3651
  """Command-center rollup for the Mission Control home — ONE call that
 
826
  ("/api/dna/generate", (6, 60)), # autoregressive — priciest
827
  ("/api/compiler", (30, 60)), # pure logic; probes DNA reach
828
  ("/api/dna", (12, 60)),
829
+ ("/api/constructs", (90, 60)), # cheap CRUD; the
830
+ # switcher polls it
831
  ("/api/plasmid", (60, 60)),
832
  ("/api/ping", (60, 60)), # dwell heartbeat — its own
833
  # bucket so ~2/min never eats
 
2200
  return jsonify({"error": "Alignment failed — check the two sequences."}), 500
2201
  return jsonify({"ok": True, **result})
2202
 
2203
+ # ── Attribution ───────────────────────────────────────────────────────
2204
+ # The whole point of the construct being the selected object: when a tool
2205
+ # saves something and the client sent the id it was working under, the
2206
+ # link is RECORDED at the moment it happened rather than reconstructed
2207
+ # later by matching name strings.
2208
+ #
2209
+ # Deliberately best-effort and silent. Attribution failing must never fail
2210
+ # the save the user actually asked for — the artifact still exists in its
2211
+ # own table, it is only the cross-reference that is missing.
2212
+ def _attribute(auth, body, kind, result):
2213
+ try:
2214
+ cid = str((body or {}).get("construct_id") or "").strip()
2215
+ new_id = (result or {}).get("id")
2216
+ if not cid or not new_id or auth.anonymous:
2217
+ return
2218
+ _auth.attach_artifact(auth.user_id, cid, kind, str(new_id))
2219
+ except Exception: # noqa: BLE001
2220
+ app.logger.warning("attribution failed for %s", kind, exc_info=True)
2221
+
2222
  @app.post("/api/plasmid/save")
2223
  def plasmid_save() -> Response:
2224
  auth, denied = _plasmid_signin_guard()
 
2239
  source=str(body.get("source", "") or "")[:16] or None,
2240
  sequence=data["sequence"], features=data["features"],
2241
  )
2242
+ _attribute(auth, body, "plasmid", result)
2243
  _auth.cleanup_expired_plasmids_async(auth.user_id)
2244
  return jsonify(result), (200 if result.get("ok") else 502)
2245
 
 
2783
  input_length=input_length,
2784
  guides=guides,
2785
  )
2786
+ _attribute(auth, body, "crispr", result)
2787
  _auth.cleanup_expired_crispr_designs_async(auth.user_id) # lazy sweep
2788
  return jsonify(result), (200 if result.get("ok") else 502)
2789
 
 
2838
  n_primers=n_primers,
2839
  result=result_obj,
2840
  )
2841
+ _attribute(auth, body, "primer", result)
2842
  _auth.cleanup_expired_primer_analyses_async(auth.user_id) # lazy sweep
2843
  return jsonify(result), (200 if result.get("ok") else 502)
2844
 
 
3670
  data["ok"] = True
3671
  return _public_cors(jsonify(data))
3672
 
3673
+ # ── Constructs: the object every tool operates on ────────────────────
3674
+ # The inversion. Until now a tool was a destination you pasted a sequence
3675
+ # into, and each of the eight tool views had its own empty input. Nothing
3676
+ # carried across them, so "which project does this guide set belong to?"
3677
+ # could only ever be reconstructed afterwards by matching name strings.
3678
+ #
3679
+ # A construct is the noun. The tools become verbs applied to it. Once the
3680
+ # client holds a selected construct and sends its id with every tool call,
3681
+ # attribution stops being a reconstruction and becomes a record.
3682
+ #
3683
+ # SIGNED OUT these endpoints all 401 by design, and the client keeps its
3684
+ # selection purely in local storage. Public tool access was a deliberate
3685
+ # decision and must not regress: nothing here may become a precondition
3686
+ # for running a tool.
3687
+ def _construct_out(row):
3688
+ """Trim a row for the client. The full DNA/protein is only sent by the
3689
+ single-construct GET — the switcher must not ship megabytes."""
3690
+ if not row:
3691
+ return None
3692
+ return {
3693
+ "id": row.get("id"), "name": row.get("name"),
3694
+ "phase": row.get("phase") or "Design",
3695
+ "sequence_hash": row.get("sequence_hash"),
3696
+ "wt_identifier": row.get("wt_identifier"),
3697
+ "created_at": row.get("created_at"),
3698
+ "updated_at": row.get("updated_at"),
3699
+ "n_plasmids": len(row.get("plasmid_ids") or []),
3700
+ "n_crispr": len(row.get("crispr_ids") or []),
3701
+ "n_primers": len(row.get("primer_ids") or []),
3702
+ "library_id": row.get("library_id"),
3703
+ }
3704
+
3705
+ @app.get("/api/constructs")
3706
+ def constructs_list() -> Response:
3707
+ auth = _auth.get_auth()
3708
+ if auth.anonymous:
3709
+ # Not an error. A signed-out visitor has no saved projects and the
3710
+ # UI shows its local-only selection instead.
3711
+ return jsonify({"ok": True, "gated": True, "constructs": []})
3712
+ rows = _auth.list_constructs(auth.user_id)
3713
+ return jsonify({"ok": True, "gated": False,
3714
+ "constructs": [_construct_out(r) for r in rows]})
3715
+
3716
+ @app.post("/api/constructs")
3717
+ def constructs_create() -> Response:
3718
+ """Create, or return the one already grouping this sequence.
3719
+
3720
+ Pasting the same sequence twice means "the thing I am already working
3721
+ on", not a second project. Silently creating a duplicate is how a
3722
+ workspace turns into a list the user has to tidy, so the hash decides.
3723
+ """
3724
+ auth = _auth.get_auth()
3725
+ if auth.anonymous:
3726
+ return jsonify({"error": "Sign in to keep projects.",
3727
+ "kind": "signin_required"}), 401
3728
+ body = request.get_json(force=True, silent=True) or {}
3729
+ dna = "".join(str(body.get("sequence_dna") or "").split()).upper()
3730
+ protein = "".join(str(body.get("wt_protein") or "").split()).upper()
3731
+ basis = dna or protein
3732
+ if not basis:
3733
+ return jsonify({"error": "A project needs a sequence."}), 400
3734
+ seq_hash = _hash_sequence(basis)
3735
+
3736
+ existing = _auth.find_construct_by_hash(auth.user_id, seq_hash)
3737
+ if existing:
3738
+ return jsonify({"ok": True, "reused": True,
3739
+ "construct": _construct_out(existing)})
3740
+
3741
+ res = _auth.save_construct(
3742
+ auth.user_id,
3743
+ name=str(body.get("name") or "").strip() or "Untitled construct",
3744
+ sequence_hash=seq_hash,
3745
+ wt_identifier=str(body.get("wt_identifier") or ""),
3746
+ wt_protein=protein, sequence_dna=dna,
3747
+ phase=str(body.get("phase") or "Design"))
3748
+ if not res.get("ok"):
3749
+ return jsonify({"error": res.get("error") or "Couldn't create."}), 400
3750
+ return jsonify({"ok": True, "reused": False,
3751
+ "construct": _construct_out(res.get("construct"))})
3752
+
3753
+ @app.get("/api/constructs/<construct_id>")
3754
+ def constructs_get(construct_id: str) -> Response:
3755
+ auth = _auth.get_auth()
3756
+ if auth.anonymous:
3757
+ return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401
3758
+ row = _auth.get_construct(auth.user_id, construct_id)
3759
+ if not row:
3760
+ return jsonify({"error": "Not found."}), 404
3761
+ out = _construct_out(row)
3762
+ # Only here, and only for the one the user actually selected.
3763
+ out["sequence_dna"] = row.get("sequence_dna") or ""
3764
+ out["wt_protein"] = row.get("wt_protein") or ""
3765
+ return jsonify({"ok": True, "construct": out})
3766
+
3767
+ @app.patch("/api/constructs/<construct_id>")
3768
+ def constructs_patch(construct_id: str) -> Response:
3769
+ auth = _auth.get_auth()
3770
+ if auth.anonymous:
3771
+ return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401
3772
+ body = request.get_json(force=True, silent=True) or {}
3773
+ res = _auth.update_construct(
3774
+ auth.user_id, construct_id,
3775
+ **{k: body[k] for k in ("name", "phase") if k in body})
3776
+ if not res.get("ok"):
3777
+ return jsonify({"error": res.get("error") or "Couldn't update."}), 400
3778
+ return jsonify({"ok": True, "construct": _construct_out(res.get("construct"))})
3779
+
3780
+ @app.delete("/api/constructs/<construct_id>")
3781
+ def constructs_delete(construct_id: str) -> Response:
3782
+ auth = _auth.get_auth()
3783
+ if auth.anonymous:
3784
+ return jsonify({"error": "Sign in.", "kind": "signin_required"}), 401
3785
+ res = _auth.delete_construct(auth.user_id, construct_id)
3786
+ if not res.get("ok"):
3787
+ return jsonify({"error": res.get("error") or "Couldn't delete."}), 400
3788
+ return jsonify({"ok": True})
3789
+
3790
  @app.get("/api/mission")
3791
  def mission() -> Response:
3792
  """Command-center rollup for the Mission Control home — ONE call that
dee/static/app.css CHANGED
@@ -10160,3 +10160,72 @@ body.de-agent-run .dna-edit-actions { display: none; }
10160
  .tc-arch { flex-wrap: wrap; }
10161
  .tc-arch-seg { flex-basis: 48%; }
10162
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10160
  .tc-arch { flex-wrap: wrap; }
10161
  .tc-arch-seg { flex-basis: 48%; }
10162
  }
10163
+
10164
+ /* ── The active construct: chip + switcher ──────────────────────────────
10165
+ The topbar used to name the TOOL you were in. Now, when something is
10166
+ selected, it names what you are working ON and the tool is demoted to the
10167
+ subtitle. With nothing selected these rules are inert and the topbar is
10168
+ exactly what it was — which is what a signed-out visitor still gets. */
10169
+ .ctx-chip {
10170
+ display: inline-flex; align-items: center; gap: 8px;
10171
+ background: none; border: 1px solid var(--line); border-radius: 999px;
10172
+ padding: 4px 12px 4px 10px; cursor: pointer; max-width: 100%;
10173
+ min-height: 32px; color: var(--ink-strong);
10174
+ font-family: inherit; transition: border-color .15s, background .15s;
10175
+ }
10176
+ /* `hidden` must win. An explicit display: beats the UA's [hidden] rule, so
10177
+ without this the chip renders even with nothing selected — which is exactly
10178
+ the signed-out first-run screen. Caught by tests/test_hidden_attribute.py,
10179
+ which exists because this has bitten the codebase before. */
10180
+ .ctx-chip[hidden] { display: none; }
10181
+ .ctx-chip:hover { border-color: var(--ink-faint); background: var(--bg-subtle); }
10182
+ .ctx-chip:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
10183
+ .ctx-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
10184
+ background: var(--ink-faint); }
10185
+ [data-theme="dark"] .ctx-dot { background: var(--glow); }
10186
+ .ctx-name { font-size: 14px; font-weight: 600; letter-spacing: -0.01em;
10187
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
10188
+ .ctx-meta { font-size: 11px; color: var(--ink-faint); white-space: nowrap; }
10189
+ .ctx-caret { font-size: 9px; color: var(--ink-faint); flex-shrink: 0; }
10190
+
10191
+ /* The demotion. Selecting a construct makes the tool line secondary rather
10192
+ than hiding it — you still need to know which tool you're holding. */
10193
+ body[data-ctx="on"] .topbar-title { display: none; }
10194
+ body[data-ctx="on"] .topbar-sub { font-size: 11.5px; color: var(--ink-faint); }
10195
+
10196
+ .ctx-menu {
10197
+ position: absolute; z-index: 60; top: 52px; left: 56px;
10198
+ min-width: 290px; max-width: min(420px, calc(100vw - 32px));
10199
+ max-height: min(60vh, 460px); overflow-y: auto;
10200
+ background: var(--bg-card); border: 1px solid var(--line);
10201
+ border-radius: 10px; box-shadow: var(--elev-4); padding: 6px;
10202
+ }
10203
+ .ctx-menu-hd { font-size: 10px; letter-spacing: .10em; text-transform: uppercase;
10204
+ color: var(--ink-faint); padding: 8px 10px 6px; }
10205
+ .ctx-menu-empty { font-size: 12px; color: var(--ink-faint); padding: 4px 10px 10px;
10206
+ line-height: 1.5; }
10207
+ .ctx-menu-sep { height: 1px; background: var(--line); margin: 6px 0; }
10208
+ .ctx-item, .ctx-act {
10209
+ display: flex; width: 100%; align-items: baseline; gap: 10px;
10210
+ background: none; border: 0; border-radius: 7px; cursor: pointer;
10211
+ padding: 9px 10px; text-align: left; color: var(--ink-strong);
10212
+ font-family: inherit; font-size: 13px; min-height: 40px;
10213
+ }
10214
+ .ctx-item:hover, .ctx-act:hover { background: var(--bg-hover); }
10215
+ .ctx-item.is-on { background: var(--bg-subtle); }
10216
+ .ctx-item.is-on .ctx-item-nm::after { content: ' · current'; color: var(--ink-faint);
10217
+ font-size: 11px; font-weight: 400; }
10218
+ .ctx-item-nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
10219
+ white-space: nowrap; font-weight: 500; }
10220
+ .ctx-item-mt { font-size: 10.5px; color: var(--ink-faint); flex-shrink: 0; }
10221
+ .ctx-act { color: var(--ink-soft); font-size: 12.5px; }
10222
+
10223
+ @media (max-width: 720px) {
10224
+ .ctx-name { font-size: 13px; }
10225
+ .ctx-meta { font-size: 12px; }
10226
+ .ctx-caret { font-size: 12px; }
10227
+ .ctx-menu { left: 12px; right: 12px; min-width: 0; top: 56px; }
10228
+ .ctx-item, .ctx-act, .ctx-menu-hd, .ctx-menu-empty,
10229
+ .ctx-item-mt { font-size: 12px; }
10230
+ body[data-ctx="on"] .topbar-sub { font-size: 12px; }
10231
+ }
dee/static/app.js CHANGED
@@ -473,26 +473,47 @@ function showRoute(name) {
473
  const href = (a.getAttribute('href') || '').replace('#', '').toLowerCase();
474
  a.classList.toggle('active', href === name);
475
  });
476
- // Contextual topbar: show the current tool + a one-line description so
477
- // the user always knows where they are and what the tool does (replaces
478
- // the old static "Engine" title).
479
- const TOPBAR = {
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  mission: ['Mission control', 'Your constructs and the loop'],
481
  structure: ['Structure', 'Predicted 3-D model of your target'],
482
  plasmid: ['Plasmid Editor', 'Map, annotate & clone your construct'],
483
  design: ['Directed Evolution', 'ESM-2 variant libraries from a wild-type'],
484
- crispr: ['CRISPR', 'Guide RNA design knockout & base editing'],
 
 
485
  primers: ['Primer Analysis', 'Score & rank your candidate PCR primers'],
486
  docs: ['Documentation', 'How TuringDNA works'],
487
- turing: ['Turing', 'Talk to the engine it runs your tools'],
488
  };
489
- const tb = TOPBAR[name] || ['Engine', ''];
490
  const tTitle = document.getElementById('topbarTitle');
491
  const tSub = document.getElementById('topbarSub');
492
  if (tTitle) tTitle.textContent = tb[0];
493
- if (tSub) tSub.textContent = tb[1];
494
- window.scrollTo({ top: 0, behavior: 'instant' });
 
 
 
 
 
495
  }
 
496
 
497
  window.addEventListener('hashchange', () => showRoute(currentRoute()));
498
  showRoute(currentRoute());
@@ -9630,6 +9651,7 @@ function runOracle(opts){
9630
  function sendToCrispr(seq, label) {
9631
  seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
9632
  if (seq.length < 23) { _toast('Need at least ~23 bp to design CRISPR guides.'); return; }
 
9633
  const ta = document.getElementById('crisprInput');
9634
  if (!ta) return;
9635
  ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
@@ -9641,6 +9663,7 @@ function runOracle(opts){
9641
  function sendToPrimers(seq, label) {
9642
  seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
9643
  if (!seq) { _toast('Nothing to send.'); return; }
 
9644
  const t = document.getElementById('primerTemplate');
9645
  if (!t) return;
9646
  t.value = seq; t.dispatchEvent(new Event('input', { bubbles: true }));
@@ -9655,6 +9678,7 @@ function runOracle(opts){
9655
  // posture as the CRISPR / Primer hand-offs.
9656
  seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
9657
  if (seq.length < 30) { _toast('Need ≥30 bp of coding sequence to evolve a protein.'); return; }
 
9658
  const ta = document.getElementById('pasteArea');
9659
  if (!ta) return;
9660
  ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
 
473
  const href = (a.getAttribute('href') || '').replace('#', '').toLowerCase();
474
  a.classList.toggle('active', href === name);
475
  });
476
+ // Contextual topbar. The map moved to _applyTopbar it now has two
477
+ // triggers (route change AND construct change) and two copies would drift.
478
+ _applyTopbar(name);
479
+ // Carry the sequence into whichever tool the user just opened. Only fills
480
+ // an input they left empty — see rule 3 in context.js.
481
+ if (window.TDContext) window.TDContext.prefill(name);
482
+ window.scrollTo({ top: 0, behavior: 'instant' });
483
+ }
484
+
485
+ // Topbar labelling. Extracted from showRoute because it has two triggers,
486
+ // not one: the route changing AND the construct changing. Inline in showRoute
487
+ // it only ran on navigation, so adopting a construct while already on a view
488
+ // left the tool line un-demoted.
489
+ let _lastRoute = 'mission';
490
+ function _applyTopbar(name) {
491
+ _lastRoute = name || _lastRoute;
492
+ const TB = {
493
  mission: ['Mission control', 'Your constructs and the loop'],
494
  structure: ['Structure', 'Predicted 3-D model of your target'],
495
  plasmid: ['Plasmid Editor', 'Map, annotate & clone your construct'],
496
  design: ['Directed Evolution', 'ESM-2 variant libraries from a wild-type'],
497
+ dna: ['DNA Design', 'Score & generate DNA with Evo 2'],
498
+ compiler: ['Therapeutic Compiler', 'Variant \u2192 editing strategy'],
499
+ crispr: ['CRISPR', 'Guide RNA design \u2014 knockout & base editing'],
500
  primers: ['Primer Analysis', 'Score & rank your candidate PCR primers'],
501
  docs: ['Documentation', 'How TuringDNA works'],
502
+ turing: ['Turing', 'Talk to the engine \u2014 it runs your tools'],
503
  };
504
+ const tb = TB[_lastRoute] || ['Engine', ''];
505
  const tTitle = document.getElementById('topbarTitle');
506
  const tSub = document.getElementById('topbarSub');
507
  if (tTitle) tTitle.textContent = tb[0];
508
+ // With a construct selected the tool line is DEMOTED to a subtitle under
509
+ // the construct's name: the answer to "where am I?" is the thing you are
510
+ // working on, not the tool you happen to be holding. Nothing selected and
511
+ // this is byte-for-byte the old topbar.
512
+ const ctx = window.TDContext && window.TDContext.get();
513
+ if (tSub) tSub.textContent = ctx ? (tb[1] ? tb[0] + ' \u00b7 ' + tb[1] : tb[0])
514
+ : tb[1];
515
  }
516
+ if (window.TDContext) window.TDContext.subscribe(() => _applyTopbar());
517
 
518
  window.addEventListener('hashchange', () => showRoute(currentRoute()));
519
  showRoute(currentRoute());
 
9651
  function sendToCrispr(seq, label) {
9652
  seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
9653
  if (seq.length < 23) { _toast('Need at least ~23 bp to design CRISPR guides.'); return; }
9654
+ if (window.TDContext) window.TDContext.adopt(seq, label, { quiet: true });
9655
  const ta = document.getElementById('crisprInput');
9656
  if (!ta) return;
9657
  ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
 
9663
  function sendToPrimers(seq, label) {
9664
  seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
9665
  if (!seq) { _toast('Nothing to send.'); return; }
9666
+ if (window.TDContext) window.TDContext.adopt(seq, label, { quiet: true });
9667
  const t = document.getElementById('primerTemplate');
9668
  if (!t) return;
9669
  t.value = seq; t.dispatchEvent(new Event('input', { bubbles: true }));
 
9678
  // posture as the CRISPR / Primer hand-offs.
9679
  seq = (seq || '').toUpperCase().replace(/[^ACGTN]/g, '');
9680
  if (seq.length < 30) { _toast('Need ≥30 bp of coding sequence to evolve a protein.'); return; }
9681
+ if (window.TDContext) window.TDContext.adopt(seq, label, { quiet: true });
9682
  const ta = document.getElementById('pasteArea');
9683
  if (!ta) return;
9684
  ta.value = seq; ta.dispatchEvent(new Event('input', { bubbles: true }));
dee/static/context.js ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ═══════════════════════════════════════════════════════════════════════
2
+ TDContext — the active construct.
3
+
4
+ THE INVERSION THIS FILE EXISTS FOR
5
+ ----------------------------------
6
+ Until now a tool was a DESTINATION. Eight routes, eight views, each with
7
+ its own empty input box, each asking for the sequence again. The "hand-off"
8
+ between them was a string copied from one <textarea> into another, and the
9
+ backend never learned which project any run belonged to — so "what work
10
+ belongs together?" could only be reconstructed afterwards by matching name
11
+ strings, which merges every artifact left at its default name and splits a
12
+ project the moment you rename something.
13
+
14
+ Here the construct is the noun and the tools are verbs applied to it. One
15
+ selection, held in one place, carried across every view and attached to
16
+ every request. Attribution stops being a reconstruction and becomes a
17
+ record — which is also what makes a real lineage graph possible later,
18
+ without hand-instrumenting each hand-off path.
19
+
20
+ THREE RULES, and each one is a way this could become worse than what it
21
+ replaces:
22
+
23
+ 1. SIGNED OUT IT MUST BEHAVE EXACTLY AS BEFORE. Public tool access was a
24
+ deliberate decision. A visitor with no account keeps a purely local
25
+ selection; nothing here may become a precondition for running a tool.
26
+ 2. IT MUST NEVER BLOCK. Pasting a new sequence switches the context
27
+ silently. The moment this can say "no", it is modal, and modal is worse
28
+ than scattered.
29
+ 3. IT MUST NEVER CLOBBER. Pre-filling only ever touches an input the user
30
+ has left EMPTY. Overwriting something they typed would be the single
31
+ fastest way to make people distrust the whole idea.
32
+ ═══════════════════════════════════════════════════════════════════════ */
33
+ (function () {
34
+ 'use strict';
35
+
36
+ var LS_KEY = 'td.construct.v1';
37
+ var listeners = [];
38
+ var active = null; // {id?, name, dna, protein, identifier, phase}
39
+ var known = []; // server-side list, for the switcher
40
+
41
+ /* ── state ──────────────────────────────────────────────────────── */
42
+ function load() {
43
+ try {
44
+ var raw = localStorage.getItem(LS_KEY);
45
+ active = raw ? JSON.parse(raw) : null;
46
+ } catch (e) { active = null; }
47
+ }
48
+ function persist() {
49
+ try {
50
+ if (active) localStorage.setItem(LS_KEY, JSON.stringify(active));
51
+ else localStorage.removeItem(LS_KEY);
52
+ } catch (e) { /* private mode — the session still works, just not across reloads */ }
53
+ }
54
+ function notify() {
55
+ listeners.forEach(function (fn) {
56
+ try { fn(active); } catch (e) { console.error(e); }
57
+ });
58
+ }
59
+
60
+ function get() { return active; }
61
+ function id() { return active && active.id ? active.id : null; }
62
+
63
+ function set(c, opts) {
64
+ active = c || null;
65
+ persist();
66
+ notify();
67
+ if (!(opts && opts.quiet) && active) {
68
+ toast('Working on ' + active.name);
69
+ }
70
+ }
71
+ function clear() { set(null); notify(); }
72
+ function subscribe(fn) { listeners.push(fn); try { fn(active); } catch (e) {} }
73
+
74
+ function toast(msg) {
75
+ if (typeof window.showToast === 'function') window.showToast(msg, 'info');
76
+ }
77
+
78
+ /* ── adopting a sequence ────────────────────────────────────────── */
79
+ // Called whenever a sequence enters the app by ANY route — pasted, fetched,
80
+ // imported, or handed over from another tool. Switches silently; creates
81
+ // server-side only when signed in. Rule 2: this never refuses.
82
+ function adopt(seq, label, opts) {
83
+ opts = opts || {};
84
+ var dna = String(seq || '').replace(/\s+/g, '').toUpperCase();
85
+ if (dna.length < 12) return Promise.resolve(active);
86
+
87
+ // Same sequence = same work. Do not spawn a second project for it.
88
+ if (active && active.dna === dna) return Promise.resolve(active);
89
+
90
+ var local = {
91
+ id: null,
92
+ name: label || defaultName(dna),
93
+ dna: dna,
94
+ protein: opts.protein || '',
95
+ identifier: opts.identifier || '',
96
+ phase: 'Design',
97
+ };
98
+ set(local, { quiet: !!opts.quiet });
99
+
100
+ // Signed out this is where it ends, and that is a complete experience:
101
+ // the selection still threads every view for this session.
102
+ return fetch('/api/constructs', {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({
106
+ sequence_dna: dna, wt_protein: local.protein,
107
+ name: local.name, wt_identifier: local.identifier,
108
+ }),
109
+ }).then(function (r) {
110
+ if (!r.ok) return null; // 401 signed out — expected, not an error
111
+ return r.json();
112
+ }).then(function (d) {
113
+ if (!d || !d.ok || !d.construct) return active;
114
+ // Server wins on identity, the local copy wins on payload: the
115
+ // list endpoint deliberately does not ship the sequence back.
116
+ active = Object.assign({}, local, {
117
+ id: d.construct.id,
118
+ name: d.construct.name || local.name,
119
+ phase: d.construct.phase || 'Design',
120
+ });
121
+ persist(); notify(); refreshList();
122
+ return active;
123
+ }).catch(function () { return active; });
124
+ }
125
+
126
+ function defaultName(dna) {
127
+ // Never "Untitled". A name you cannot tell apart from four others is
128
+ // the failure mode the name-string grouping already suffers from.
129
+ return 'Construct ' + dna.slice(0, 6) + '…' + dna.slice(-4);
130
+ }
131
+
132
+ /* ── the server-side list, for the switcher ─────────────────────── */
133
+ function refreshList() {
134
+ return fetch('/api/constructs').then(function (r) {
135
+ return r.ok ? r.json() : null;
136
+ }).then(function (d) {
137
+ known = (d && d.constructs) || [];
138
+ notify();
139
+ return known;
140
+ }).catch(function () { return known; });
141
+ }
142
+ function list() { return known; }
143
+
144
+ function open(constructId) {
145
+ return fetch('/api/constructs/' + encodeURIComponent(constructId))
146
+ .then(function (r) { return r.ok ? r.json() : null; })
147
+ .then(function (d) {
148
+ if (!d || !d.construct) return null;
149
+ var c = d.construct;
150
+ set({
151
+ id: c.id, name: c.name, dna: c.sequence_dna || '',
152
+ protein: c.wt_protein || '', identifier: c.wt_identifier || '',
153
+ phase: c.phase || 'Design',
154
+ });
155
+ return active;
156
+ }).catch(function () { return null; });
157
+ }
158
+
159
+ function rename(name) {
160
+ if (!active) return Promise.resolve(null);
161
+ active.name = String(name || '').slice(0, 120) || active.name;
162
+ persist(); notify();
163
+ if (!active.id) return Promise.resolve(active);
164
+ return fetch('/api/constructs/' + encodeURIComponent(active.id), {
165
+ method: 'PATCH',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify({ name: active.name }),
168
+ }).then(function () { refreshList(); return active; })
169
+ .catch(function () { return active; });
170
+ }
171
+
172
+ /* ── the interceptor ────────────────────────────────────────────── */
173
+ // ONE place, not twenty. Editing every tool's fetch call by hand would
174
+ // have meant instrumenting ~20 sites and still missing whichever one gets
175
+ // added next month — and a lineage with a silent hole in it is worse than
176
+ // no lineage, because you cannot tell which is which.
177
+ var TOOL_PREFIXES = ['/api/crispr', '/api/primers', '/api/plasmid',
178
+ '/api/design', '/api/dna', '/api/compiler',
179
+ '/api/de/', '/api/align', '/api/structure'];
180
+ var nativeFetch = window.fetch.bind(window);
181
+
182
+ function isToolCall(url) {
183
+ var u = String(url || '');
184
+ for (var i = 0; i < TOOL_PREFIXES.length; i++) {
185
+ if (u.indexOf(TOOL_PREFIXES[i]) === 0) return true;
186
+ }
187
+ return false;
188
+ }
189
+
190
+ window.fetch = function (input, init) {
191
+ try {
192
+ var url = (typeof input === 'string') ? input
193
+ : (input && input.url) || '';
194
+ var cid = id();
195
+ if (cid && init && init.method &&
196
+ String(init.method).toUpperCase() === 'POST' &&
197
+ isToolCall(url) && typeof init.body === 'string') {
198
+ var body = JSON.parse(init.body);
199
+ if (body && typeof body === 'object' && !Array.isArray(body)
200
+ && !body.construct_id) {
201
+ body.construct_id = cid;
202
+ init = Object.assign({}, init, { body: JSON.stringify(body) });
203
+ }
204
+ }
205
+ } catch (e) {
206
+ // A body that is not JSON, or is not ours to touch. Attribution is
207
+ // never worth breaking the request the user actually made.
208
+ }
209
+ return nativeFetch(input, init);
210
+ };
211
+
212
+ /* ── pre-filling a tool view ────────────────────────────────────── */
213
+ // Rule 3: only ever fills an input the user left EMPTY.
214
+ var TOOL_INPUTS = {
215
+ crispr: 'crisprInput',
216
+ primers: 'primerTemplate',
217
+ design: 'pasteArea',
218
+ compiler: 'tcWindow',
219
+ dna: 'dnaReference',
220
+ plasmid: 'plasmidInput',
221
+ };
222
+
223
+ function prefill(route) {
224
+ if (!active || !active.dna) return false;
225
+ var elId = TOOL_INPUTS[route];
226
+ if (!elId) return false;
227
+ var el = document.getElementById(elId);
228
+ if (!el || (el.value || '').trim()) return false; // never clobber
229
+ el.value = active.dna;
230
+ el.dispatchEvent(new Event('input', { bubbles: true }));
231
+ return true;
232
+ }
233
+
234
+ load();
235
+ window.TDContext = {
236
+ get: get, id: id, set: set, clear: clear, subscribe: subscribe,
237
+ adopt: adopt, open: open, rename: rename, list: list,
238
+ refreshList: refreshList, prefill: prefill,
239
+ TOOL_INPUTS: TOOL_INPUTS,
240
+ };
241
+
242
+ // The list is only meaningful signed in; a 401 here is normal and silent.
243
+ if (document.readyState === 'loading') {
244
+ document.addEventListener('DOMContentLoaded', function () { refreshList(); });
245
+ } else { refreshList(); }
246
+ }());
247
+
248
+ /* ═══════════════════════════════════════════════════════════════════════
249
+ The chip + switcher.
250
+
251
+ Rule 3 has a UI corollary: switching and clearing must be one click and
252
+ always visible. A context you cannot see or change is worse than no
253
+ context — you end up editing the wrong object and only find out later.
254
+ ═══════════════════════════════════════════════════════════════════════ */
255
+ (function () {
256
+ 'use strict';
257
+ var C = window.TDContext;
258
+ if (!C) return;
259
+
260
+ function esc(s) {
261
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (m) {
262
+ return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;',
263
+ '"': '&quot;', "'": '&#39;' })[m];
264
+ });
265
+ }
266
+ function bp(n) { return n ? n.toLocaleString() + ' bp' : ''; }
267
+
268
+ function init() {
269
+ var chip = document.getElementById('ctxChip');
270
+ var menu = document.getElementById('ctxMenu');
271
+ if (!chip || !menu) return;
272
+
273
+ C.subscribe(function (c) {
274
+ chip.hidden = !c;
275
+ document.body.setAttribute('data-ctx', c ? 'on' : 'off');
276
+ if (!c) return;
277
+ var nm = document.getElementById('ctxName');
278
+ var mt = document.getElementById('ctxMeta');
279
+ if (nm) nm.textContent = c.name || 'Construct';
280
+ if (mt) {
281
+ // The un-saved case is stated, not hidden. A signed-out user
282
+ // whose selection lives only in this browser should know that.
283
+ mt.textContent = [bp((c.dna || '').length),
284
+ c.phase || '',
285
+ c.id ? '' : 'this browser only']
286
+ .filter(Boolean).join(' · ');
287
+ }
288
+ });
289
+
290
+ function close() {
291
+ menu.hidden = true;
292
+ chip.setAttribute('aria-expanded', 'false');
293
+ }
294
+
295
+ function render() {
296
+ var cur = C.get();
297
+ var rows = C.list() || [];
298
+ var h = '<div class="ctx-menu-hd">Constructs</div>';
299
+ if (!rows.length) {
300
+ h += '<div class="ctx-menu-empty">No saved projects yet. '
301
+ + 'Paste or fetch a sequence and it becomes one.</div>';
302
+ }
303
+ rows.forEach(function (r) {
304
+ var on = cur && cur.id === r.id;
305
+ h += '<button class="ctx-item' + (on ? ' is-on' : '') + '" role="menuitem"'
306
+ + ' data-open="' + esc(r.id) + '">'
307
+ + '<span class="ctx-item-nm">' + esc(r.name) + '</span>'
308
+ + '<span class="ctx-item-mt mono">' + esc(r.phase || 'Design')
309
+ + (r.n_plasmids ? ' · ' + r.n_plasmids + 'p' : '')
310
+ + (r.n_crispr ? ' · ' + r.n_crispr + 'g' : '')
311
+ + (r.n_primers ? ' · ' + r.n_primers + 'pr' : '')
312
+ + '</span></button>';
313
+ });
314
+ h += '<div class="ctx-menu-sep"></div>';
315
+ if (cur) {
316
+ h += '<button class="ctx-act" role="menuitem" data-rename="1">Rename…</button>';
317
+ h += '<button class="ctx-act" role="menuitem" data-clear="1">'
318
+ + 'Work without a construct</button>';
319
+ }
320
+ menu.innerHTML = h;
321
+ }
322
+
323
+ chip.addEventListener('click', function (e) {
324
+ e.stopPropagation();
325
+ if (!menu.hidden) { close(); return; }
326
+ render();
327
+ menu.hidden = false;
328
+ chip.setAttribute('aria-expanded', 'true');
329
+ C.refreshList().then(function () { if (!menu.hidden) render(); });
330
+ });
331
+
332
+ menu.addEventListener('click', function (e) {
333
+ var t = e.target.closest('[data-open],[data-clear],[data-rename]');
334
+ if (!t) return;
335
+ e.stopPropagation();
336
+ if (t.dataset.open) { C.open(t.dataset.open); close(); return; }
337
+ if (t.dataset.clear) {
338
+ // Deliberately NOT called "delete". Clearing the selection
339
+ // must never read as destroying the work.
340
+ C.clear(); close(); return;
341
+ }
342
+ if (t.dataset.rename) {
343
+ var cur = C.get();
344
+ var next = window.prompt('Rename this construct', cur && cur.name);
345
+ if (next) C.rename(next);
346
+ close();
347
+ }
348
+ });
349
+
350
+ document.addEventListener('click', function () { if (!menu.hidden) close(); });
351
+ document.addEventListener('keydown', function (e) {
352
+ if (e.key === 'Escape' && !menu.hidden) close();
353
+ });
354
+ }
355
+
356
+ if (document.readyState === 'loading') {
357
+ document.addEventListener('DOMContentLoaded', init);
358
+ } else { init(); }
359
+ }());
dee/static/index.html CHANGED
@@ -112,7 +112,7 @@
112
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
113
  app.js change. Bump these numbers whenever you ship a frontend update —
114
  without them, users keep getting the stale file for up to a week. -->
115
- <link rel="stylesheet" href="/static/app.css?v=20260818-peg3" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
@@ -496,10 +496,27 @@
496
  <line x1="3" y1="18" x2="21" y2="18"/>
497
  </svg>
498
  </button>
 
 
 
 
 
 
 
499
  <div class="topbar-titles">
 
 
 
 
 
 
 
 
 
500
  <h1 class="topbar-title" id="topbarTitle">Plasmid Editor</h1>
501
  <span class="topbar-sub" id="topbarSub">Map, annotate &amp; clone your construct</span>
502
  </div>
 
503
  <!-- Theme toggle relocated to the sidebar footer, by the account chip
504
  (see #themeToggle in the aside) — it now stays reachable in the bench
505
  view, where this topbar is hidden. -->
@@ -2852,7 +2869,8 @@
2852
  <!-- Cloning reference data must load before app.js so the Designer
2853
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2854
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2855
- <script src="/static/app.js?v=20260818-peg3" defer></script>
 
2856
  <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
2857
  on the very first event, and both are `defer`, so document order is
2858
  load order. Loading it after would drop the opening events of a
 
112
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
113
  app.js change. Bump these numbers whenever you ship a frontend update —
114
  without them, users keep getting the stale file for up to a week. -->
115
+ <link rel="stylesheet" href="/static/app.css?v=20260818-ctx2" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
 
496
  <line x1="3" y1="18" x2="21" y2="18"/>
497
  </svg>
498
  </button>
499
+ <!-- The construct chip. THE inversion, in markup: when a
500
+ construct is selected it becomes the primary line and the
501
+ tool name is demoted to a subtitle — you are working on a
502
+ thing, using a tool, not visiting a tool. With nothing
503
+ selected the chip is hidden and the tool title takes the
504
+ lead, which is exactly today's behaviour and therefore
505
+ exactly what a signed-out visitor still gets. -->
506
  <div class="topbar-titles">
507
+ <button class="ctx-chip" id="ctxChip" type="button" hidden
508
+ aria-haspopup="menu" aria-expanded="false"
509
+ aria-controls="ctxMenu"
510
+ title="Switch or clear the construct every tool is working on">
511
+ <span class="ctx-dot" aria-hidden="true"></span>
512
+ <span class="ctx-name" id="ctxName">&mdash;</span>
513
+ <span class="ctx-meta mono" id="ctxMeta"></span>
514
+ <span class="ctx-caret" aria-hidden="true">&#9662;</span>
515
+ </button>
516
  <h1 class="topbar-title" id="topbarTitle">Plasmid Editor</h1>
517
  <span class="topbar-sub" id="topbarSub">Map, annotate &amp; clone your construct</span>
518
  </div>
519
+ <div class="ctx-menu" id="ctxMenu" role="menu" hidden></div>
520
  <!-- Theme toggle relocated to the sidebar footer, by the account chip
521
  (see #themeToggle in the aside) — it now stays reachable in the bench
522
  view, where this topbar is hidden. -->
 
2869
  <!-- Cloning reference data must load before app.js so the Designer
2870
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2871
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2872
+ <script src="/static/context.js?v=20260818-ctx2" defer></script>
2873
+ <script src="/static/app.js?v=20260818-ctx2" defer></script>
2874
  <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
2875
  on the very first event, and both are `defer`, so document order is
2876
  load order. Loading it after would drop the opening events of a
tests/test_construct_context.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The construct as the object every tool operates on.
2
+
3
+ THE INVERSION UNDER TEST
4
+ ------------------------
5
+ A tool used to be a destination: eight routes, eight empty inputs, and a
6
+ "hand-off" that copied a string from one <textarea> into another. Nothing
7
+ carried across them and the backend never learned which project a run belonged
8
+ to, so /api/mission had to reconstruct "constructs" by grouping artifacts on a
9
+ normalised NAME STRING — which merges everything left at a default name and
10
+ splits a project the moment you rename something.
11
+
12
+ These tests pin the three properties that decide whether the replacement is
13
+ better than what it replaces, because each is a way it could be worse:
14
+
15
+ 1. SIGNED OUT, NOTHING CHANGES. Public tool access was deliberate. If any of
16
+ this becomes a precondition for running a tool, the change is a
17
+ regression no matter how tidy the workspace looks.
18
+ 2. THE SAME SEQUENCE IS THE SAME PROJECT. Pasting twice must not spawn a
19
+ duplicate — that is how a workspace becomes a list the user has to tidy.
20
+ 3. ATTRIBUTION IS BEST-EFFORT AND NEVER FATAL. A failed cross-reference must
21
+ not fail the save the user actually asked for.
22
+ """
23
+ import json
24
+
25
+ import pytest
26
+
27
+ from dee import auth as dee_auth
28
+ from dee import server
29
+
30
+
31
+ def _client():
32
+ app = server.create_app()
33
+ app.config.update(TESTING=True)
34
+ return app.test_client()
35
+
36
+
37
+ class _Auth:
38
+ def __init__(self, user_id=None):
39
+ self.user_id = user_id
40
+ self.anonymous = user_id is None
41
+
42
+
43
+ # --------------------------------------------------------------------------- #
44
+ # 1. Signed out, nothing changes
45
+ # --------------------------------------------------------------------------- #
46
+ def test_listing_constructs_signed_out_is_an_empty_gate_not_an_error(monkeypatch):
47
+ """A 200 with gated:true, not a 401. The switcher has to render for a
48
+ signed-out visitor without treating them as a failure."""
49
+ monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth(None))
50
+ body = _client().get("/api/constructs").get_json()
51
+ assert body == {"ok": True, "gated": True, "constructs": []}
52
+
53
+
54
+ @pytest.mark.parametrize("method,path", [
55
+ ("post", "/api/constructs"),
56
+ ("get", "/api/constructs/11111111-1111-1111-1111-111111111111"),
57
+ ("patch", "/api/constructs/11111111-1111-1111-1111-111111111111"),
58
+ ("delete", "/api/constructs/11111111-1111-1111-1111-111111111111"),
59
+ ])
60
+ def test_writing_constructs_signed_out_is_refused_with_a_signin_kind(
61
+ monkeypatch, method, path):
62
+ monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth(None))
63
+ cl = _client()
64
+ res = getattr(cl, method)(path, json={"sequence_dna": "ACGT" * 20})
65
+ assert res.status_code == 401
66
+ assert res.get_json().get("kind") == "signin_required"
67
+
68
+
69
+ def test_the_client_keeps_its_own_selection_when_signed_out():
70
+ """context.js must not depend on the server for the signed-out path — the
71
+ selection lives in localStorage and still threads every view."""
72
+ with open("dee/static/context.js", encoding="utf-8") as fh:
73
+ src = fh.read()
74
+ assert "localStorage" in src
75
+ # adopt() posts, but a non-ok response must be swallowed, not thrown.
76
+ assert "if (!r.ok) return null;" in src
77
+
78
+
79
+ def test_context_never_blocks_a_tool_run():
80
+ """Rule 2. There must be no code path where a missing construct prevents
81
+ a request — the interceptor only ever ADDS a field."""
82
+ with open("dee/static/context.js", encoding="utf-8") as fh:
83
+ src = fh.read()
84
+ assert "return nativeFetch(input, init);" in src
85
+ # Exactly ONE call site, so no early bail-out can creep in later: every
86
+ # path through the wrapper ends at the same return.
87
+ assert src.count("nativeFetch(") == 1
88
+
89
+
90
+ def test_prefill_never_clobbers_what_the_user_typed():
91
+ """Rule 3. Overwriting a filled input is the fastest way to make the whole
92
+ idea untrustworthy."""
93
+ with open("dee/static/context.js", encoding="utf-8") as fh:
94
+ src = fh.read()
95
+ assert "if (!el || (el.value || '').trim()) return false; // never clobber" in src
96
+
97
+
98
+ # --------------------------------------------------------------------------- #
99
+ # 2. The same sequence is the same project
100
+ # --------------------------------------------------------------------------- #
101
+ def test_posting_the_same_sequence_twice_reuses_the_construct(monkeypatch):
102
+ monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
103
+ existing = {"id": "abc", "name": "MC1R", "phase": "Build",
104
+ "sequence_hash": "deadbeef"}
105
+ monkeypatch.setattr(server._auth, "find_construct_by_hash",
106
+ lambda uid, h: existing)
107
+ called = {"n": 0}
108
+
109
+ def _never(*a, **k):
110
+ called["n"] += 1
111
+ return {"ok": True, "id": "new"}
112
+ monkeypatch.setattr(server._auth, "save_construct", _never)
113
+
114
+ body = _client().post("/api/constructs",
115
+ json={"sequence_dna": "ACGT" * 20}).get_json()
116
+ assert body["ok"] is True and body["reused"] is True
117
+ assert body["construct"]["id"] == "abc"
118
+ assert called["n"] == 0, "a duplicate project must not be created"
119
+
120
+
121
+ def test_a_construct_needs_a_sequence(monkeypatch):
122
+ monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
123
+ res = _client().post("/api/constructs", json={"name": "no sequence"})
124
+ assert res.status_code == 400
125
+
126
+
127
+ def test_the_list_payload_does_not_ship_the_sequence(monkeypatch):
128
+ """The switcher renders every project. Shipping the DNA with each would
129
+ make a routine dropdown megabytes."""
130
+ monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
131
+ monkeypatch.setattr(server._auth, "list_constructs", lambda uid: [{
132
+ "id": "a", "name": "N", "phase": "Design", "sequence_hash": "h",
133
+ "sequence_dna": "ACGT" * 5000, "wt_protein": "MKV" * 100,
134
+ "plasmid_ids": ["p1"], "crispr_ids": [], "primer_ids": [],
135
+ }])
136
+ row = _client().get("/api/constructs").get_json()["constructs"][0]
137
+ assert "sequence_dna" not in row and "wt_protein" not in row
138
+ assert row["n_plasmids"] == 1 and row["n_crispr"] == 0
139
+
140
+
141
+ def test_the_single_get_does_ship_the_sequence(monkeypatch):
142
+ """...because that one is the construct the user actually selected, and
143
+ the tools need it to pre-fill."""
144
+ monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
145
+ monkeypatch.setattr(server._auth, "get_construct", lambda uid, cid: {
146
+ "id": cid, "name": "N", "phase": "Design",
147
+ "sequence_dna": "ACGTACGT", "wt_protein": "MKV",
148
+ })
149
+ c = _client().get(
150
+ "/api/constructs/11111111-1111-1111-1111-111111111111"
151
+ ).get_json()["construct"]
152
+ assert c["sequence_dna"] == "ACGTACGT" and c["wt_protein"] == "MKV"
153
+
154
+
155
+ # --------------------------------------------------------------------------- #
156
+ # 3. Attribution is best-effort and never fatal
157
+ # --------------------------------------------------------------------------- #
158
+ def test_attach_rejects_a_bad_kind_or_id():
159
+ assert dee_auth.attach_artifact("u1", "not-a-uuid", "plasmid", "x")["ok"] is False
160
+ good = "11111111-1111-1111-1111-111111111111"
161
+ assert dee_auth.attach_artifact("u1", good, "nonsense", good)["ok"] is False
162
+
163
+
164
+ def test_phase_only_ever_moves_forward(monkeypatch):
165
+ """Opening the CRISPR tool after logging results must not drag a project
166
+ back from Learn to Edit."""
167
+ good = "11111111-1111-1111-1111-111111111111"
168
+ monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
169
+ "id": cid, "phase": "Learn", "crispr_ids": []})
170
+ seen = {}
171
+
172
+ def _upd(uid, cid, **fields):
173
+ seen.update(fields)
174
+ return {"ok": True, "construct": {}}
175
+ monkeypatch.setattr(dee_auth, "update_construct", _upd)
176
+
177
+ dee_auth.attach_artifact("u1", good, "crispr", good)
178
+ assert seen["crispr_ids"] == [good]
179
+ assert "phase" not in seen, "Learn must not regress to Edit"
180
+
181
+
182
+ def test_phase_advances_when_it_should(monkeypatch):
183
+ good = "11111111-1111-1111-1111-111111111111"
184
+ monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
185
+ "id": cid, "phase": "Design", "crispr_ids": []})
186
+ seen = {}
187
+ monkeypatch.setattr(dee_auth, "update_construct",
188
+ lambda uid, cid, **f: (seen.update(f),
189
+ {"ok": True, "construct": {}})[1])
190
+ dee_auth.attach_artifact("u1", good, "crispr", good)
191
+ assert seen["phase"] == "Edit"
192
+
193
+
194
+ def test_attaching_the_same_artifact_twice_is_a_no_op(monkeypatch):
195
+ good = "11111111-1111-1111-1111-111111111111"
196
+ monkeypatch.setattr(dee_auth, "get_construct", lambda uid, cid: {
197
+ "id": cid, "phase": "Build", "plasmid_ids": [good]})
198
+ monkeypatch.setattr(dee_auth, "update_construct",
199
+ lambda *a, **k: pytest.fail("should not write"))
200
+ assert dee_auth.attach_artifact("u1", good, "plasmid", good)["already"] is True
201
+
202
+
203
+ def test_a_failing_attribution_does_not_fail_the_save(monkeypatch):
204
+ """The artifact still exists in its own table; only the cross-reference is
205
+ missing. Losing the save instead would be strictly worse."""
206
+ monkeypatch.setattr(server._auth, "get_auth", lambda: _Auth("u1"))
207
+ monkeypatch.setattr(server._auth, "save_plasmid",
208
+ lambda *a, **k: {"ok": True, "id": "p-1"})
209
+ monkeypatch.setattr(server._auth, "cleanup_expired_plasmids_async",
210
+ lambda uid: None)
211
+
212
+ def _boom(*a, **k):
213
+ raise RuntimeError("supabase down")
214
+ monkeypatch.setattr(server._auth, "attach_artifact", _boom)
215
+
216
+ res = _client().post("/api/plasmid/save", json={
217
+ "name": "p", "topology": "circular", "sequence": "ACGT" * 30,
218
+ "features": [], "construct_id": "11111111-1111-1111-1111-111111111111",
219
+ })
220
+ assert res.status_code == 200 and res.get_json()["ok"] is True
221
+
222
+
223
+ def test_user_id_cannot_be_passed_as_a_field_at_all():
224
+ """The strongest version of the check: `user_id` is a positional parameter
225
+ of update_construct, so a caller splatting a request body that contains it
226
+ gets a TypeError rather than a silent ownership change. Asserted because
227
+ it is a property of the signature that a future refactor to **kwargs would
228
+ quietly remove."""
229
+ good = "11111111-1111-1111-1111-111111111111"
230
+ with pytest.raises(TypeError):
231
+ dee_auth.update_construct("u1", good, **{"user_id": "attacker"})
232
+
233
+
234
+ def test_update_ignores_fields_the_client_should_not_write(monkeypatch):
235
+ """Everything outside the allow-list is dropped before the row is touched
236
+ — a TTL or an id from a request body must never reach it."""
237
+ good = "11111111-1111-1111-1111-111111111111"
238
+ captured = {}
239
+ import urllib.request
240
+
241
+ class _Resp:
242
+ def __enter__(self): return self
243
+ def __exit__(self, *a): return False
244
+ def read(self): return b"[]"
245
+
246
+ def _fake(req, timeout=0):
247
+ captured["body"] = json.loads(req.data.decode())
248
+ return _Resp()
249
+ monkeypatch.setattr(dee_auth, "SUPABASE_URL", "https://x")
250
+ monkeypatch.setattr(dee_auth, "SUPABASE_SERVICE_KEY", "k")
251
+ monkeypatch.setattr(urllib.request, "urlopen", _fake)
252
+
253
+ dee_auth.update_construct("u1", good, name="ok", expires_at="never",
254
+ id="other", sequence_hash="forged", phase="Build")
255
+ assert "expires_at" not in captured["body"]
256
+ assert "id" not in captured["body"]
257
+ assert "sequence_hash" not in captured["body"]
258
+ assert captured["body"]["name"] == "ok"
259
+ assert captured["body"]["phase"] == "Build"