Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
506 changes: 506 additions & 0 deletions apps/api/internal/plugins/frontend/handler.go

Large diffs are not rendered by default.

232 changes: 232 additions & 0 deletions apps/api/internal/plugins/frontend/handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package frontend

import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestHandler_RegisterAndServeBundle(t *testing.T) {
h := NewHandler(nil)
src := []byte(`export const hi = "hello";` + "\n")
err := h.Register(PluginBundle{
Slug: "seo",
Entries: []BundleEntry{
{Path: "seo.mjs", Bytes: src},
},
Imports: map[string]string{
"@plugin/seo": "/api/plugins/seo/web/seo.mjs",
},
})
if err != nil {
t.Fatalf("Register: %v", err)
}

req := httptest.NewRequest(http.MethodGet, "/api/plugins/seo/web/seo.mjs", nil)
rec := httptest.NewRecorder()
h.ServeBundle(rec, req)
resp := rec.Result()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status: got %d want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != string(src) {
t.Errorf("body: got %q", body)
}
if ct := resp.Header.Get("Content-Type"); ct != "application/javascript" {
t.Errorf("content-type: got %q", ct)
}
if sri := resp.Header.Get("X-SRI"); !strings.HasPrefix(sri, "sha256-") {
t.Errorf("X-SRI: got %q", sri)
}
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "immutable") {
t.Errorf("cache-control: got %q", cc)
}
if etag := resp.Header.Get("ETag"); etag == "" || !strings.HasPrefix(etag, `"`) {
t.Errorf("ETag: got %q", etag)
}
}

func TestHandler_NotFound(t *testing.T) {
h := NewHandler(nil)
req := httptest.NewRequest(http.MethodGet, "/api/plugins/none/web/foo.mjs", nil)
rec := httptest.NewRecorder()
h.ServeBundle(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("status: %d", rec.Code)
}
}

func TestHandler_PathTraversalRejected(t *testing.T) {
h := NewHandler(nil)
_ = h.Register(PluginBundle{
Slug: "p",
Entries: []BundleEntry{{Path: "ok.mjs", Bytes: []byte("ok")}},
})
req := httptest.NewRequest(http.MethodGet, "/api/plugins/p/web/../etc/passwd", nil)
rec := httptest.NewRecorder()
h.ServeBundle(rec, req)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
t.Errorf("traversal: got %d", rec.Code)
}
}

func TestHandler_RegisterRejectsBadPath(t *testing.T) {
h := NewHandler(nil)
err := h.Register(PluginBundle{
Slug: "p",
Entries: []BundleEntry{{Path: "../bad.mjs", Bytes: []byte("x")}},
})
if err == nil {
t.Errorf("expected error on bad path")
}
}

func TestHandler_ImportMapComposition(t *testing.T) {
h := NewHandler(nil)
_ = h.Register(PluginBundle{
Slug: "a",
Entries: []BundleEntry{{Path: "a.mjs", Bytes: []byte("a")}},
Imports: map[string]string{"@plugin/a": "/api/plugins/a/web/a.mjs"},
})
_ = h.Register(PluginBundle{
Slug: "b",
Entries: []BundleEntry{{Path: "b.mjs", Bytes: []byte("b")}},
Imports: map[string]string{"@plugin/b": "/api/plugins/b/web/b.mjs"},
})

req := httptest.NewRequest(http.MethodGet, "/api/plugins/import-map.json", nil)
rec := httptest.NewRecorder()
h.ServeImportMap(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: %d", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/importmap+json" {
t.Errorf("content-type: %q", ct)
}
var parsed struct {
Imports map[string]string `json:"imports"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &parsed); err != nil {
t.Fatalf("unmarshal: %v body=%s", err, rec.Body.String())
}
if parsed.Imports["@plugin/a"] != "/api/plugins/a/web/a.mjs" {
t.Errorf("a: %v", parsed.Imports)
}
if parsed.Imports["@plugin/b"] != "/api/plugins/b/web/b.mjs" {
t.Errorf("b: %v", parsed.Imports)
}
}

func TestHandler_ImportCollisionRejected(t *testing.T) {
h := NewHandler(nil)
_ = h.Register(PluginBundle{
Slug: "a",
Imports: map[string]string{"shared": "/api/plugins/a/web/x.mjs"},
})
err := h.Register(PluginBundle{
Slug: "b",
Imports: map[string]string{"shared": "/api/plugins/b/web/y.mjs"},
})
if err == nil {
t.Errorf("expected collision error")
}
}

func TestHandler_Unregister(t *testing.T) {
h := NewHandler(nil)
_ = h.Register(PluginBundle{
Slug: "a",
Entries: []BundleEntry{{Path: "a.mjs", Bytes: []byte("a")}},
Imports: map[string]string{"@plugin/a": "/api/plugins/a/web/a.mjs"},
})
h.Unregister("a")
req := httptest.NewRequest(http.MethodGet, "/api/plugins/a/web/a.mjs", nil)
rec := httptest.NewRecorder()
h.ServeBundle(rec, req)
if rec.Code != http.StatusNotFound {
t.Errorf("after unregister: %d", rec.Code)
}
if got := h.ImportMapSnapshot(); len(got) != 0 {
t.Errorf("imports remaining: %v", got)
}
}

func TestHandler_NotModified(t *testing.T) {
h := NewHandler(nil)
_ = h.Register(PluginBundle{
Slug: "p",
Entries: []BundleEntry{{Path: "p.mjs", Bytes: []byte("x")}},
})
req1 := httptest.NewRequest(http.MethodGet, "/api/plugins/p/web/p.mjs", nil)
rec1 := httptest.NewRecorder()
h.ServeBundle(rec1, req1)
etag := rec1.Header().Get("ETag")
if etag == "" {
t.Fatal("no etag")
}

req2 := httptest.NewRequest(http.MethodGet, "/api/plugins/p/web/p.mjs", nil)
req2.Header.Set("If-None-Match", etag)
rec2 := httptest.NewRecorder()
h.ServeBundle(rec2, req2)
if rec2.Code != http.StatusNotModified {
t.Errorf("304: got %d", rec2.Code)
}
}

func TestHandler_SRIByURL(t *testing.T) {
h := NewHandler(nil)
_ = h.Register(PluginBundle{
Slug: "p",
Entries: []BundleEntry{{Path: "p.mjs", Bytes: []byte("hello")}},
})
sri := h.SRIByURL("/api/plugins/p/web/p.mjs")
if !strings.HasPrefix(sri, "sha256-") {
t.Errorf("sri: %q", sri)
}
if h.SRIByURL("/api/plugins/p/web/missing.mjs") != "" {
t.Errorf("expected empty SRI for unknown URL")
}
}

func TestHandler_ImportMapScriptTag(t *testing.T) {
h := NewHandler(nil)
_ = h.Register(PluginBundle{
Slug: "a",
Imports: map[string]string{"@plugin/a": "/api/plugins/a/web/a.mjs"},
})
tag := h.ImportMapScriptTag()
if !strings.HasPrefix(tag, `<script type="importmap">`) {
t.Errorf("tag: %q", tag)
}
if !strings.Contains(tag, "@plugin/a") {
t.Errorf("missing key: %q", tag)
}
if !strings.HasSuffix(tag, "</script>") {
t.Errorf("trailer: %q", tag)
}
}

func TestHandler_HEADResponse(t *testing.T) {
h := NewHandler(nil)
_ = h.Register(PluginBundle{
Slug: "p",
Entries: []BundleEntry{{Path: "p.mjs", Bytes: []byte("xxxxxxxxxxxx")}},
})
req := httptest.NewRequest(http.MethodHead, "/api/plugins/p/web/p.mjs", nil)
rec := httptest.NewRecorder()
h.ServeBundle(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("head: %d", rec.Code)
}
if rec.Body.Len() != 0 {
t.Errorf("head body: %q", rec.Body.String())
}
if rec.Header().Get("Content-Length") == "" {
t.Errorf("missing content-length")
}
}
6 changes: 6 additions & 0 deletions migrations/000039_plugin_version_log.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- 000039_plugin_version_log.down.sql

DROP INDEX IF EXISTS plugin_version_log_retention_end_idx;
DROP INDEX IF EXISTS plugin_version_log_retained_idx;
DROP INDEX IF EXISTS plugin_version_log_active_idx;
DROP TABLE IF EXISTS plugin_version_log;
107 changes: 107 additions & 0 deletions migrations/000039_plugin_version_log.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
-- 000039_plugin_version_log.up.sql
--
-- Versioned-update tracking for the plugin lifecycle (issue #63).
--
-- The plugins table holds a single row per slug — the *current*
-- active version. When the operator rolls out a new version, the
-- lifecycle Manager:
--
-- 1. Loads the new bundle's WASM into the runtime side-by-side
-- with the previous version.
-- 2. Records the new version in this table as 'active' and flips
-- the previous row to 'retiring' atomically.
-- 3. Drains in-flight requests against the previous version (poll
-- on the in-process drainTracker; default 30s timeout).
-- 4. Marks the previous row 'retained' with a retention_end of
-- now + 24h so a rollback is a cheap promote.
-- 5. A cron job calls PurgeExpired which deletes rows whose
-- retention_end < now and any rows already marked 'retired'.
--
-- The previous marketplace table `plugin_versions` (000019) tracks
-- *published* versions in the catalog — distinct from this table,
-- which tracks the *installed* version log on a specific host. We
-- name this table plugin_version_log to avoid the collision.
--
-- Depends on:
-- * the runtime plugins table (referenced by slug FK only — the
-- FK is intentionally NOT declared because we want the version
-- log to survive an Uninstall + Reinstall cycle for audit
-- purposes; the cleanup cron deletes orphans).

CREATE TABLE plugin_version_log (
-- UUID v7 PK — same convention as the marketplace plugin_versions
-- table, which lets the version log sort time-ascending by id.
id UUID PRIMARY KEY DEFAULT gen_uuid_v7(),

-- The plugin slug this row tracks. Not a foreign key (see file
-- comment) but indexed for the dominant access pattern: "show me
-- every recorded version for plugin X".
slug TEXT NOT NULL
CHECK (slug ~ '^[a-z][a-z0-9-]{2,40}$'),

-- The version string at the time of install. Stored as text;
-- semver comparison is done at the application layer using the
-- same library the catalog uses.
version TEXT NOT NULL
CHECK (length(version) > 0 AND length(version) <= 64),

-- ABI version the bundle declared. Tracked here so a rollback
-- can re-establish the right ABI guards without re-reading the
-- bundle.
abi_version INT NOT NULL CHECK (abi_version > 0),

-- One of: 'active', 'retiring', 'retained', 'retired'.
-- Constrained at the DB layer so a buggy caller can't poison the
-- log; the lifecycle.VersionState constants are the source of
-- truth for what each value means.
state TEXT NOT NULL
CHECK (state IN ('active', 'retiring', 'retained', 'retired')),

installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),

-- When the row most recently transitioned to 'active'. Set on
-- AppendActive and on PromoteToActive; null for rows that were
-- never active (none today, but the column lets a future "stage
-- but don't activate" gesture record itself here).
activated_at TIMESTAMPTZ,

-- When the row transitioned out of 'active' into 'retiring'.
-- Null while the row is current.
retired_at TIMESTAMPTZ,

-- When the row becomes eligible for purge. Null on active rows.
-- The cleanup cron deletes rows whose retention_end < now.
retention_end TIMESTAMPTZ,

-- A given (slug, version) pair appears at most once in the log.
-- A re-install of the same version is a no-op; rollback toggles
-- state on the existing row.
UNIQUE (slug, version)
);

COMMENT ON TABLE plugin_version_log IS
'Per-host version log used by the lifecycle Manager for atomic update / rollback / retention (issue #63).';
COMMENT ON COLUMN plugin_version_log.state IS
'active = current; retiring = draining post-swap; retained = warm for rollback; retired = unloaded, awaiting cron purge.';
COMMENT ON COLUMN plugin_version_log.retention_end IS
'When a retained row becomes eligible for cron purge. Null on active / retiring / retired rows.';

-- Partial index for "find the active version for slug X" — single-row
-- per slug invariant lets this index degenerate to a unique constraint
-- on (slug) WHERE state='active'. Postgres treats partial unique
-- indexes as proper constraints, which is exactly what we want here.
CREATE UNIQUE INDEX plugin_version_log_active_idx
ON plugin_version_log (slug)
WHERE state = 'active';

-- "Show me every retained version for this slug, newest first" —
-- the dominant Rollback read pattern.
CREATE INDEX plugin_version_log_retained_idx
ON plugin_version_log (slug, installed_at DESC)
WHERE state = 'retained';

-- The cleanup cron walks rows ordered by retention_end so a single
-- index scan covers the entire purge pass.
CREATE INDEX plugin_version_log_retention_end_idx
ON plugin_version_log (retention_end)
WHERE retention_end IS NOT NULL;
Loading
Loading