Visitar URL original
Tags · InsForge/instacloud-oss · GitHub
Skip to content

Tags

Tags

gate-v0.1.1

Toggle gate-v0.1.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix(gate): refuse a WebSocket or state change sent from another page (#…

…234)

* fix(gate): refuse a WebSocket or state change sent from another page

A browser sends the gate's SameSite=Lax session cookie on requests from any
page on the same site, and every *.compute.instacloud-edge.com service is one
site, since the domain is not on the Public Suffix List. So another tenant's
page could open a signed-in visitor's terminal WebSocket (a root shell) or post
to the app behind the gate. Verified on a deployed instance: an upgrade with a
sibling subdomain's Origin and a valid cookie got 101. ttyd's own -c resisted
this through its /token step, which the gate replaced.

With a valid session the gate now answers 403 to a WebSocket upgrade, and to
POST, PUT, PATCH and DELETE, when the Origin is not this service's, or with no
Origin, when Sec-Fetch-Site is anything but same-origin or none. Reads stay
open, since the browser does not show their response to the other page. A
request with neither header is not a browser's and still needs the cookie.

Gate 0.1.0 -> 0.1.1.

* fix(gate): close the other same-site paths, and pin 0.1.1 in the docs

Review of the cross-page fix, plus a threat-model pass with every tenant as a
same-site page, found more ways in:

- A framed terminal opens its WebSocket from its own origin, past the Origin
  check, and the iframe gets the cookie. Every proxied response now carries
  X-Frame-Options: SAMEORIGIN (unless the app set its own) and a
  frame-ancestors 'self' CSP beside the app's.
- /_insta/sign-out cleared the cookie for any page, an <img> included. It now
  refuses a request another page sent, like the other state changes.
- A sibling could toss a cookie of the same name in through the shared parent
  domain. Over https the cookie is __Host-insta_gate and only that name is read.
- Reads from another page stay open because the browser hides them. A response
  to another page now loses any Access-Control-Allow-Origin/-Credentials the
  app sent, so an app answering with CORS cannot undo that.
- ttyd and the agent in the shell no longer inherit ADMIN_USERNAME and
  ADMIN_PASSWORD. A root process can still read the gate's environment from
  /proc, so this removes the easy path to sending them out, not every path.
- The README and spec examples pinned gate-v0.1.0, the version without the
  cross-page check. They pin 0.1.1.

Tests for each, all failing against the previous commit, and for the cases
that must stay allowed: a state change with no provenance header or with
Sec-Fetch-Site: none, and sign-out typed into the address bar.

gate-v0.1.0

Toggle gate-v0.1.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat(gate): an InstaCloud sign-in page to put in front of ttyd (#232)

* feat(gate): an InstaCloud sign-in page to put in front of ttyd

claude-code, codex and pi guard a root shell with ttyd's own basic auth, so a
visitor gets the browser's native dialog with nothing on it saying InstaCloud.
gate/ is the replacement: one Node file (built-ins only) that serves a sign-in
card, checks ADMIN_USERNAME and ADMIN_PASSWORD, keeps a signed session cookie,
and proxies a signed-in browser to ttyd on loopback, WebSocket included. It
starts ttyd as its child and exits with its status.

The page is rendered at build time from @insforge/ui's Button and Input and
styled by ui/src/styles.css, the repository's copy of the console theme, so it
follows the console in light and dark (by the visitor's system setting) with no
hand-picked colors.

.github/workflows/gate.yml builds it and tests the built file on pull requests,
and a gate-v<version> tag publishes insta-gate.mjs and its SHA-256 as a release.
The templates switch to it in a follow-up, downloading that release by version
and checksum the way they download ttyd.

Spec: plans/2026-10-08-ttyd-login-gate-spec.md

* fix(gate): require a same-origin sign-in, and fail a start that never gets a terminal

Review found two gaps.

The sign-in POST passed when it carried neither Origin nor Sec-Fetch-Site, and
an Origin was compared by host alone, so an https deployment accepted
Origin: http://<same host>. It now compares the whole origin against the one
served (X-Forwarded-Proto and X-Forwarded-Host, which the platform router and
the self-hosted router both set), refuses Origin: null, and without an Origin
lets through only Sec-Fetch-Site: same-origin.

The gate waited ten seconds for ttyd and then listened regardless, so a
terminal that stayed up without binding left a deploy reporting healthy while
every signed-in request got a 502. It now waits up to --ready-timeout seconds
(default 30), and on timeout stops the terminal and exits 1.

* test(gate): the gate forwards SIGTERM, SIGINT and SIGHUP to the terminal

The platform stops a container by signalling its main process, which is now the
gate. A real gate process with a terminal that records the signal it got checks
each of the three: the terminal hears it, and the gate exits with the
terminal's status. Removing the forwarding fails all three.

* fix(gate): rate-limit sign-in before comparing credentials, and close review nits

A delay on failed attempts did not cap guessing: parallel POSTs each waited on
their own timer and a right guess answered at once. Every sign-in now takes a
token from one global bucket (burst 10, then 1 per second) before the
credentials are compared, and past it gets a 429 with Retry-After and a
"Too many sign-in attempts" message. Global, because behind the router the
address the gate sees is a shared proxy's and the forwarded chain is the
client's to write.

Also: gate.yml runs when ui/package.json, the root manifests or .nvmrc change,
the README installs the root Vitest before testing, and the spec says the gate
is built and documents the limit.

v0.4.1

Toggle v0.4.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: bundle buildx in the daemon image (git push-to-deploy needs Buil…

…dKit) — v0.4.1 (#169)

* fix: bundle the buildx plugin in the daemon image (v0.4.1)

Git push-to-deploy builds the pushed repo with BuildKit (git-context fetch + the
GIT_AUTH_TOKEN build secret), which needs the docker buildx plugin. The image copied
only the `docker` CLI binary from docker:28.5.2-cli, not buildx, so on the box
`docker build --secret` (DOCKER_BUILDKIT=1) errored immediately and every push-to-
deploy build failed with a ~40ms git.deploy.failed. Copy the buildx plugin into the
image's cli-plugins dir too.

Found by running the real feature on the live box (v0.4.0): the unit/int tests pass
because the dev machine has buildx; the shipped image did not. Verified the fix by
hotfixing buildx into the running container and re-triggering — the box then built
docker/welcome-to-docker from git and redeployed SHA-pinned (io-git-…:68c1b9f87c41).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test: assert the image ships docker buildx (regression guard for v0.4.0 gap)

cubic P2: nothing verified buildx was in the image — exactly how v0.4.0 shipped
broken, since image.int.test.ts checked the docker CLI but not the plugin, and the
dev host's own buildx masked the gap everywhere else. Assert `docker buildx version`
runs against the built image (needs no daemon). Verified: fails on the old image,
passes on the fixed one (buildx v0.29.1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

v0.4.0

Toggle v0.4.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
release: v0.4.0 (#167)

* release: v0.4.0 (native git push-to-deploy, #159)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* release: sync package-lock.json to 0.4.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

v0.3.0

Toggle v0.3.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
chore(release): v0.3.0 (#158)

Bundles device-code login (insta login --device, #157), the README refresh
(#156), and the local-mode readiness + e2e fixes already on main since v0.2.0.
Tagging v0.3.0 after this merges builds ghcr.io/insforge/instacloud:0.3.0.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

v0.2.0

Toggle v0.2.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Cloud parity: Buckets file browser, branch rename/exclude, 404, Deplo…

…yment Logs, redis key browser, SQL editor + data tabs (#144)

* fix: Add Service dialogs start with an empty name

The console pre-fills the Service Name, safe there because Add only
stages a change behind Deploy/Discard; here Add creates the service
immediately, so an untouched dialog silently created a real service.
The name now starts empty with the unique suggestion as the
placeholder, and Add stays disabled until one is typed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: render the console's 404 page for unknown routes

The router's catch-all silently replaced unknown paths with /, which
hid typos in pasted links; the console renders Next.js's default
not-found page (A09).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: branch rename in the dashboard + exclude all services on create

Rename Branch joins the branches row menu, wired to the daemon's
existing PATCH /projects/:id/branches/:bid; renaming the branch you
stand on renavigates, since the URL carries the name.

Branch create takes the console's excludeServices flag: an empty
branch, none of the parent's services, secrets, or secret bindings
copied. The daemon provisions only the branch shell (ref, network,
row), needs no service keys in the lock set, and marks the
branch.created event excludedServices. Known divergence, documented in
the dialog and COMPATIBILITY: compute registrations are project-scoped
here, so they still list as not-deployed rows on the empty branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: storage Buckets tab with the console's file browser

Storage services lead with a Buckets tab (E02): the bucket card (name
with copy, the Private/Public access toggle) over a Files card with
refresh, Upload File, search, a Name / Size / Type / Uploaded At /
Actions table with per-row download and delete, bulk delete over
checkbox selection, and the console's empty state. All four object
routes go through the approval-aware client, upload is the daemon's
presigned POST straight to the store, download a 60s presigned GET.
The daemon's flat listing carries no contentType, so the Type column
and the upload's contentType derive from the key in a pure lib module
with tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: Deployment Logs tab on compute, postgres and managed services

The console's per-service deploy-event table (C02/D04), rendered from
the daemon's audit stream (GET /events) filtered to one service and one
time window: deploys, restarts, sleep/wake (with the wake's duration),
lifecycle and registration events. Divergences in the file headers: no
Status or Machine column (audit events carry neither; one machine per
group), 3-day widest preset (the metrics module's retention), and the
rows are audit events rather than machine operations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: redis key browser behind the wake gate (daemon routes + Database tab)

The console's redis Database tab (E01): logical-db chips, the key list,
click a key for its type/TTL/value, behind the same suspended -> Wake
and browse gate as Postgres. Daemon side: ManagedDbAdapter grows an
optional command() (docker exec valkey-cli, auth via REDISCLI_AUTH in
the exec env so the password never rides argv; -e is already on the
redaction list), engine redisKeys/redisValue (SCAN pages, keyspace
summary, bounded collection reads, never wakes: 503 sleeping), routes
GET .../redis/keys and /redis/value gated by a new db.read action.
Contract test covers the shapes, typed refusals and the sleeping 503.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: postgres Data browser + SQL editor + Extensions sub-tabs

The console's Database sub-tabs (D02): Data (table rail + first rows),
Editor (query tabs, Add Query, Run, result grid), Stats (the existing
insight blocks) and Extension (enable/disable over the existing
routes), all behind the wake gate. Daemon side: POST
/projects/:id/database/query runs one ad-hoc statement through the
psql exec seam; SELECT-ish statements answer columns + rows via a
json_agg wrap, others report psql's command tag; gated by a new
db.query action, 400 quoting psql's ERROR line for a refused
statement, 503 asleep (never wakes). Identifier quoting for the Data
tab is a pure lib module with hostile-name tests. Divergences in the
file headers: plain textarea (no CodeMirror), no Configurations
sub-tab (no PgBouncer; credentials stay behind insta secrets).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: postgres Configurations sub-tab (Username, Regenerate Password)

The console's Configurations view (D02): the postgres role the
connection string uses, and Regenerate Password over the daemon's
existing rotate route (server-minted; re-mints DATABASE_URL; running
containers keep the old env until their next deploy). The value is
never shown here — it stays behind the Connect dialog and insta
secrets. Divergence in the header: no PgBouncer section, the daemon
runs no pooler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: redis Stats sub-tab (INFO counters)

The console's redis Stats view (E01): memory, clients, cache-hit rate
and command counters as cards, from a new GET .../redis/stats that
picks the INFO fields into the console's shape (gated db.read, 503
asleep, same as the key reads). The Database tab now carries Data |
Stats sub-tabs; Editor and Configurations stay out (divergence in the
header: an arbitrary-command editor is a different security posture,
and credentials live behind insta secrets).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: 7-day metrics retention and range preset

The console's picker reaches 7 and 30 days; ours stopped at 3. The
daemon now keeps 7 days of 30s samples (2.3x the store, rates intact)
and the picker gains the 7 day preset, which also makes Deployment
Logs default to the console's 7-day window. 30 day stays out until a
thin-with-age design that keeps the network-rate differencing
(MAX_RATE_GAP_SEC) working across thinned spans.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: follow the OS theme by default and self-host the console's typefaces

Measured against the live console: a fresh session there follows the
OS color scheme (System default), while the self-host hard-defaulted
to dark — on a light OS the two products did not even share a
background color, the single largest "does not feel the same" cause.
The default is now System (index.html pre-paint script + lib/theme.ts
agree); an explicit Light/Dark choice still persists.

Typefaces: the console serves Inter and Geist Mono via next/font; the
self-host only listed Inter, so it rendered in the OS fallback unless
the viewer happened to have Inter installed, and code fell back to the
system mono. Both are now self-hosted through @fontsource-variable
(woff2 in the bundle, no runtime fetch to third parties).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: topbar Feedback control

The console's topbar carries Feedback between Add Branch and
Activities; without it the header shape differed on every page.
Self-host divergence in the comment: OSS feedback goes to the
repository's issues rather than the cloud's in-app form, so this is a
plain link.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round 1 (r2d2 + cubic) — bounds, classifier, approvals, ordering, audit

Redis: hash and set reads go through *SCAN pages HARD-SLICED to 200
entries (COUNT is a hint; HGETALL was unbounded); every read lands a
db.read audit event (op + db only, never keys or values); the password
now rides the docker CLIENT's env (-e NAME forwards it) so it appears
in no argv anywhere; docker/provider failures answer 502, not 400;
out-of-order listing/value responses are sequence-guarded; a read that
finds the instance asleep returns the panel to the wake gate; value
errors clear on the next pick; 'N keys loaded' counts what is loaded.

SQL editor: classification strips leading comments; SHOW/EXPLAIN run
as written (utility statements the wrapper cannot host); multi-
statement input skips the wrapper; a WITH that the wrapper cannot host
fails at parse time and falls through to ONE raw execution; every
statement lands a db.query audit event (mode + service, never SQL).

Approvals: Data/Editor/Configurations/Extensions and the redis panel
all hand a 202 to the shared approval prompt and retry the actual
operation after the grant (the redis retry was a React no-op);
DatabaseInsight page carries its own ApprovalPrompt; required
extensions render disabled with a badge.

Deployment Logs: fetches the project stream (registration events carry
branch null, which a server-side ?branch filter dropped) and scopes per
branch client-side over an explicit deploy-kind allowlist, so governed
reads never pollute the log; window edges are inclusive and tested.

Also: the overlay resets its local tab when the service changes (a
stale Buckets tab no longer queries a non-storage service); the
Database sub-tabs hold behind the first metrics answer instead of
querying a sleeping instance; upload surfaces an unreachable endpoint;
contentTypeFor uses own-property lookup (a 'report.toString' key no
longer answers with Object.prototype); STEP_LADDER gains 2h so custom
ranges past ~3.7 days are actually reachable within the promised
7-day lookback; --font-mono moves to @theme inline; stale comments
corrected; negative controls anchored to adapter verbs and to the
password's absence from recorded argv.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round 2 — exactly-once, literal-aware, lossless SQL transport

The row transport is redesigned around three guarantees the wrapper
lacked. Exactly-once: the transport is chosen up front from the MASKED
statement (src/sqlsurface.ts blanks literals, identifiers and nested
comments) and no error ever re-runs user SQL — the syntax-error
fallback could re-execute a volatile function's side effects.
Literal-aware: a ';' or an 'update' inside quoted text no longer
changes the route (a WITH is row-shaped exactly when its last
statement keyword is SELECT, so data-modifying CTEs run as commands
instead of failing the wrapper). Lossless: values travel as TEXT via
json_each_text (row_to_json + JSON.parse silently rounded bigint and
numeric past 2^53); column names and order still come from the same
single execution. The route is also server-bounded now: a 30s
statement timeout rides PGOPTIONS on the exec (adapter opt; management
and observability queries are unchanged) and the row page stops at
5000.

UI: the Data tab's loaders are state-driven, so a denied or dismissed
approval leaves a message instead of a skeleton awaiting a promise
nobody resolves; the Database page drops a pending approval when the
project or branch switches; the redis Stats sleeping callback is
memoized (its identity re-fired the gated load on every parent render
— an approval loop).

Tests: sqlsurface unit suite; the query contract now proves per-
statement exactly-once execution, text-preserved bigints, literal
semicolons, WITH…SELECT vs WITH…UPDATE routing, and the bounded
transport; the fake's canned answers follow the new wire shape.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round 3 — the wrapper runs on real Postgres, graded by a real Postgres

The lossless wrapper in 0622f51 never executed: array_agg over the
per-row text[] builds text[][], whose single subscript is a SCALAR, so
coalesce(text, text[]) failed type analysis for EVERY statement — and
the fake, keyed on a substring of the generated SQL, answered it
anyway, which is how a dead feature passed 1235 green tests. Both
halves are fixed: the column list now comes from min(cols) (btree-
defined over arrays; every row carries the same one), settled against
a live Postgres 16 before this push, and a new RUN_DOCKER_TESTS
integration suite (test/db-query.int.test.ts) grades the transport
against a real server: bigint/numeric text fidelity, column order,
zero rows, literal semicolons, VALUES/TABLE/WITH routing, command
tags, and the statement timeout actually cancelling pg_sleep.

The vacuous password test is replaced by a real one: it calls
LocalManagedDb.command against the mocked docker seam and asserts the
argv it BUILDS carries no password while the exec env does (the old
assertion read the fake's own recording, which cannot fail).

Browse-rate audit rows (db.read, db.query) now coalesce through
touchLater's deferred audit write instead of paying a synchronous
full-state save per key click; the events route takes ?kinds= applied
BEFORE the limit slice so Deployment Logs' page budget is spent on
deploy events, not on the browse rows this PR adds; and a psql
refusal quotes only its first ERROR line — the caret context pointed
into the daemon's private wrapper SQL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round 4 — one statement per request, depth-aware WITH, SQL off argv

Several statements in one request are now REFUSED (400, nothing
executes) instead of routed raw: the contract is one ad-hoc statement,
and the raw path let a request chain sub-timeout statements past the
per-statement bound and the row cap. lastStatementKeyword is depth-
aware — 'with c as (select 1) update t set v = (select 2)' is a
command by its TOP-LEVEL keyword, where the flat scan read the nested
SELECT and wrapped a statement the subquery cannot host. Both proven
against a real Postgres in test/db-query.int.test.ts (the nested
WITH...UPDATE executes; the multi-statement request rejects before any
adapter call).

The SQL now rides psql's STDIN (-X -tA -f -) instead of '-tAc <sql>'
argv, so an editor statement — which may hold data or credentials —
never sits in the host's process listing for the life of the exec.
Redis stream keys answer a bounded XRANGE page instead of a silent
null.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round 5 — psql meta-commands refused, parenthesized queries row-shaped

Over the stdin transport psql META-COMMANDS execute: two buffers
split by \g carry no semicolon past the single-statement guard, \!
runs a shell in the container, and \watch re-runs past the statement
timeout. A backslash outside string literals is never SQL (no PG
operator may contain one), so dbQuery now refuses any unquoted
backslash before the adapter is reached (400, nothing executes) —
while a backslash INSIDE a literal stays data. Proven on a real
Postgres: \watch, \g-chaining and \! all reject with zero adapter
calls; 'not a \watch' round-trips as a value.

Classification also covers parenthesized query expressions now: a
bare (select ...) and a WITH whose final query is parenthesized (no
top-level keyword at all) are row-shaped — both settled against live
Postgres 16 before pushing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: review round 6 — comment-shadowed terminators, result identity, non-execution proof

A terminal semicolon shadowed by a trailing comment ('select 1; --
done') passed the single-statement guard but survived the end-anchored
strip of the RAW text, so the wrapper embedded '...; -- done' and every
such SELECT answered 400. The statement's end is now read off the
MASKED copy (sqlsurface.trailingTrimIndex) and the original text is
sliced there, which also lets 'select 1; ; -- done' count as single.
Proven on real Postgres for line and block comment forms.

The editor binds a result to the SQL that produced it: the tab accepts
a response (or an approval retry's) only while its text still equals
the text that was sent, so a slow answer for statement A never lands
under an edited statement B.

The \g refusal test now PROVES non-execution: the first buffer is a
side-effecting INSERT and its effect is asserted absent afterwards.
plans/impl/07-dashboard.md's no-new-dependency clause is amended to
record the granted exceptions instead of presenting a retired rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: refuse psql meta-commands at the transport, not just the engine

The engine guard rejected meta-commands before dbQuery reached the
adapter, but LocalPostgres.query (psql -f -) stayed capable of running
one for any caller that forgot to guard — the defense-in-depth gap the
reviewers reproduced by calling the adapter directly. The transport
now takes sqlOnly and refuses any unquoted backslash (masking literals
first, so a backslash IN data is fine), so \!, \g, \gexec and \watch
cannot execute regardless of caller. The engine keeps its earlier,
friendlier rejection; this is the layer that cannot be bypassed.
Adapter-level test proves the refusal exec's nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix: close the E-string mask bypass that restored meta-command RCE

maskSqlText treated the trailing e/E of an identifier as an
escape-string prefix (like'z\' read as e'...'), and the E-string
backslash-escape rule then over-consumed, hiding a following \! from
the meta-command guard — a reproduced root shell in the Postgres
container through POST /database/query. An e'/E' is now an escape
prefix only when the e starts a token (no identifier char before it).
With that, the masker only ever closes a construct where psql's lexer
does, so any residual disagreement leaves a backslash VISIBLE and the
guard rejects — the safe direction. Regressions: the exact bypass at
the mask, the engine, and live Postgres (executes nothing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: release v0.2.0 — cloud-parity dashboard sweep

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

v0.1.0

Toggle v0.1.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
chore: point GitHub URLs at the renamed InstaCloud repos (#108)

* chore: point GitHub URLs at the renamed InstaCloud repos

The org's InstaCloud repos were renamed per the "Open Source InstaCloud"
decision doc: insta-oss -> instacloud-oss, insta-cli -> instacloud-cli,
insta-skills -> instacloud-skills (and the rest of the table).

GitHub redirects the old slugs on the web, git, the API, raw.githubusercontent
and jsDelivr, so nothing was broken by the rename; this just stops shipping
stale URLs to users. Measured before renaming: a repo rename does redirect on
raw.githubusercontent (denoland/deno_std -> denoland/std still serves 200), and
the daemon image is ghcr.io/insforge/instacloud, which is not derived from the
repo name and is therefore untouched.

Only owner/repo slugs change here. The product identifiers are deliberately
left alone, because they are installed-user state rather than repo names:
~/.insta-oss, the INSTA_OSS_* environment variables, the io- container prefix,
and the @insforge/insta-oss package name.

Also left alone: plans/ (historical design records) and the vendored
.agents/.claude skill copies, which sync from the skills repo and would only
drift if edited here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

* chore: update template sourceRepo slugs to instacloud-oss

The first sweep globbed .md/.ts/.sh/.json and missed the template YAMLs, whose
sourceRepo field names the repo the template registry pulls from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

* Revert "chore: update template sourceRepo slugs to instacloud-oss"

This reverts commit 959259b.

* fix: publisher must default to the renamed repo slug

r2d2 Critical: absolutizeReadme and logoUrlOf fall back to a hardcoded repo
slug when GITHUB_REPOSITORY is unset (the supported local-publish path), so a
local publish emitted README and jsDelivr logo URLs under the old name.

The GHCR package paths in the tests are deliberately NOT changed: a container
package does not move when its source repo is renamed, so published template
images genuinely still live at ghcr.io/insforge/insta-oss/templates/*.

The stripDeployBadge fixtures keep the old slug on purpose too - they stand in
for READMEs already published in the wild, and prove the badge stripper stays
slug-agnostic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

* fix: bind the rename with a test, and clarify the public package name

Review round 3 ran a negative control: reverting 28 of the 29 rewritten URLs
left tsc, eslint, the template linter, the version guard and the doc tests all
green. Only the Dockerfile LABEL was actually bound. test/repo-slugs.test.ts
binds the other 28 - it fails on any InsForge/insta-{oss,cli,skills} outside the
places where the old slug is still the correct string, and its allowlist records
why each of those exists. Verified by negative control: reintroducing one old
URL in README.md fails the test with the exact file and line.

publish.mjs: `||` rather than `??`, because an explicitly exported
GITHUB_REPOSITORY="" is empty but not nullish and would have built
cdn.jsdelivr.net/gh/@<sha>/... - a broken URL with no error. The slug is now one
DEFAULT_REPO constant instead of two literals; the duplication is what let the
original miss through.

Public naming, since nothing has been released yet and the npm package was never
published: @insforge/insta-oss -> @insforge/instacloud-oss, its bin, the ui
package, and the 27 places the docs used "insta-oss" as the product name (now
"InstaCloud OSS").

Deliberately NOT renamed, because they are installed state rather than public
names: the INSTA_OSS_* environment variables, ~/.insta-oss, the io- container
prefix, and the "insta-oss basebackup" marker that postgres.ts writes into
pg_hba.conf on live databases and greps for. The ghcr template image paths keep
the old name too - a container package does not move when its repo is renamed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

* fix: finish the product-name rename, and guard it

Critical: four doc pages still called the product `insta-oss`
(docs/compute/overview.mdx, docs/deploy/overview.mdx, docs/postgres/overview.mdx,
docs/self-hosting/overview.mdx).

They survived because the check that declared the first pass clean filtered out
whole LINES containing "InsForge/", and those four carry the product name and a
repo URL on the SAME line, so the prose was invisible to it. A false clean, not a
missed file. The verification now matches on the occurrence, never the line.

test/repo-slugs.test.ts gains that as a third assertion, since the slug guard by
construction could not see it: no bare `insta-oss` in user-facing prose, with
lookarounds that still permit the identifiers which deliberately stay
(`~/.insta-oss`, `InsForge/instacloud-oss`, `ghcr.io/insforge/insta-oss/...`,
`insta-oss-ui`). Verified by negative control on exactly the failing shape: a
line carrying both the prose and a repo URL.

Also narrowed the fixture exception from the whole publish-lib.test.mjs file to
the legacy deploy-badge URL itself, so an unrelated stale repo URL added to that
test later is still caught. Negative control confirms it now fails on one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

* fix: rename the user-visible product labels, and close a hole in the guard

Both from review suggestions, both worth taking.

The guard skipped every root-level dot directory, which silently excluded the
TRACKED .github tree - exactly where a stale workflow URL would live, and .yml
was already the blind spot that let the template manifests through. It now walks
git's index instead of the filesystem: .github is covered, untracked scratch
files are not, and it cannot drift from what a clean checkout ships. Negative
control: a stale URL appended to .github/workflows/ci.yml now fails the test.

The most public surfaces of all still said insta-oss - the browser tab title,
the dashboard sidebar, the sign-in card, the no-dashboard fallback page - plus
the two 501 error strings the CLI and agents are shown. All now "InstaCloud OSS",
with test/server.test.ts updated in lockstep, and the prose guard extended to
cover ui/src and src/server.ts so they cannot drift back.

Still deliberately untouched, because they are protocol or installed state
rather than a public name: the WWW-Authenticate realm in src/auth.ts, the
INSTA_OSS_* environment variables, ~/.insta-oss, the io- container prefix, and
the "insta-oss basebackup" marker postgres.ts writes into pg_hba.conf on live
databases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

* fix: finish the product rename in src/, and stop the guard being self-shaped

Critical: three user-visible strings still said insta-oss - the 501 body the
token endpoints return to CLI and agent callers (src/auth.ts:136), the
Docker-missing error (src/main.ts:106), and the daemon startup line
(src/main.ts:220).

They survived because the prose guard listed src/server.ts and nothing else:
the one backend file already fixed. A guard shaped around what you already
fixed proves nothing, which is the same failure as the previous round's
skipped .github tree. It now scans the WHOLE src/, ui/ and docs/ trees.

Internal code comments in main.ts, engine.ts and fsclone.cjs were renamed too,
so the guard needs no comment-vs-string exception to stay simple.

Two exceptions stay, both installed or protocol state rather than a label, and
both now explicit in the guard: the WWW-Authenticate realm in src/auth.ts:42
that a client may already be pinned to, and the "insta-oss basebackup" marker
postgres.ts writes into pg_hba.conf on live databases and then greps for.

Negative control: restoring the old startup string in src/main.ts fails the
guard with the exact file and line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

* fix: the guard must scan SVGs, which are text

cubic: `.svg` sat in the BINARY skip list, so both guards silently skipped
every tracked SVG - assets/deploy-button.svg and the template logos - any of
which can embed a repo URL. No current offender, but that is precisely the
blind spot this test exists to close, and it was in the test itself.

Negative control: a stale URL in a comment inside assets/deploy-button.svg now
fails the guard with the exact file and line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

* style: no em dashes in the text this PR adds

templates/AGENTS.md:150 says no em dashes in this repo, and the publisher
comment added here used two. Fixed there, and in the guard test's comments and
the ui package description, so the rule holds for everything this PR authored.

Left alone: the em dashes already present in the 501 error strings in
src/auth.ts and src/server.ts. This PR only renamed the product inside them;
rewriting their punctuation is unrelated scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QtUBRLn31nL66MeXpLvpbR

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>