From 8791f78803a6d37e3225d633a28a550489152898 Mon Sep 17 00:00:00 2001 From: Matthew Morris Date: Wed, 26 Aug 2026 20:19:32 -0500 Subject: [PATCH 1/2] Seed the library on a fresh deploy, before any account exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit primo deploy auto-pushes the whole workspace right after provisioning, before the operator has created an account. Sites land fine — bootstrap is unauthenticated when the server has zero sites — but import-library hard-required a token, so the library failed on every first deploy with "Authentication required" while deploy still reported success. Give import-library the same guard as bootstrap: allow an unauthenticated remote caller ONLY when the server has zero sites, and force that path strictly additive (ignore any deletes manifest) so the widened surface can never destroy records. Fail closed — a site-lookup error rejects rather than allows. The gate is extracted to a pure libraryImportAuthDecision so it's unit-testable without an HTTP harness (the handler has none). Also surface what was seeded on the setup screen: /api/primo/info now exposes library_block_count alongside site_count, and /admin/setup shows "Already loaded on this server — N sites, M library blocks" above the create-account form, so a fresh deploy confirms the workspace landed before handing off to account creation. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/import_library.go | 58 ++++++++++++++++++++++++- internal/import_library_test.go | 77 +++++++++++++++++++++++++++++++++ internal/info.go | 50 +++++++++++---------- src/lib/instance.ts | 1 + src/routes/setup/+page.svelte | 67 +++++++++++++++++++++++++++- 5 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 internal/import_library_test.go diff --git a/internal/import_library.go b/internal/import_library.go index 793cf4ca8..5fe52f139 100644 --- a/internal/import_library.go +++ b/internal/import_library.go @@ -36,13 +36,60 @@ type libraryBlockLocation struct { blockFolder string } +// libraryImportAuth is the outcome of the import-library auth gate. Exactly one +// of {allow, reject} is meaningful per call: reject != "" means deny with that +// reason; otherwise the request proceeds. freshServer marks an unauthenticated +// zero-site seed, which is forced strictly additive downstream. +type libraryImportAuth struct { + reject string // "" = allowed; otherwise "unauthorized" or "internal" + freshServer bool +} + +// libraryImportAuthDecision decides whether an import-library request may +// proceed. Authenticated or localhost callers always pass. An unauthenticated +// remote caller is allowed ONLY on a fresh server (zero sites), mirroring the +// bootstrap endpoint's guard so `primo deploy` can seed the library in the same +// pre-account window it seeds sites. We fail closed: if the site lookup errored +// we can't prove the server is fresh, so we reject rather than allow. +// +// Extracted as a pure function so the security-sensitive gate is unit-testable +// without an HTTP/PocketBase harness (the handler has no such test scaffold). +func libraryImportAuthDecision(authed, isLocal bool, siteCount int, lookupErr error) libraryImportAuth { + if authed || isLocal { + return libraryImportAuth{} + } + if lookupErr != nil { + return libraryImportAuth{reject: "internal"} + } + if siteCount > 0 { + return libraryImportAuth{reject: "unauthorized"} + } + return libraryImportAuth{freshServer: true} +} + func RegisterLibraryImportEndpoint(pb *pocketbase.PocketBase) error { pb.OnServe().BindFunc(func(serveEvent *core.ServeEvent) error { serveEvent.Router.POST("/api/primo/import-library", func(e *core.RequestEvent) error { + // Only look sites up when it can matter (unauthenticated remote + // caller); authed/localhost skip the query entirely. + authed := e.Auth != nil isLocal := IsLocalhost(e) - if e.Auth == nil && !isLocal { + siteCount := 0 + var lookupErr error + if !authed && !isLocal { + sites, err := pb.FindAllRecords("sites") + lookupErr = err + siteCount = len(sites) + } + + decision := libraryImportAuthDecision(authed, isLocal, siteCount, lookupErr) + switch decision.reject { + case "internal": + return e.InternalServerError("Failed to check existing sites", lookupErr) + case "unauthorized": return e.UnauthorizedError("Authentication required", nil) } + freshServer := decision.freshServer if err := e.Request.ParseMultipartForm(32 << 20); err != nil { return e.BadRequestError("Failed to parse form", err) @@ -71,6 +118,15 @@ func RegisterLibraryImportEndpoint(pb *pocketbase.PocketBase) error { } } + // An unauthenticated fresh-server seed is strictly additive: never + // honor a deletes manifest on that path. There's nothing to delete + // on a zero-site server anyway, but this keeps the unauthenticated + // surface incapable of destroying records even if the DB isn't + // actually empty (e.g. library records without a site). + if freshServer { + deletes = DeletesManifest{} + } + summary, err := processLibraryImport(pb, zipData, deletes) if err != nil { return e.InternalServerError("Library import failed: "+err.Error(), err) diff --git a/internal/import_library_test.go b/internal/import_library_test.go new file mode 100644 index 000000000..e5d968dec --- /dev/null +++ b/internal/import_library_test.go @@ -0,0 +1,77 @@ +package internal + +import ( + "errors" + "testing" +) + +// The import-library auth gate is security-sensitive: it opens an +// unauthenticated write path, so its exact conditions are pinned here. The +// gate must (a) always let authed/localhost callers through, (b) allow an +// unauthenticated remote caller ONLY on a fresh (zero-site) server, and +// (c) fail closed when the site lookup errors. +func TestLibraryImportAuthDecision(t *testing.T) { + lookupErr := errors.New("db down") + + cases := []struct { + name string + authed bool + isLocal bool + siteCount int + lookupErr error + wantReject string + wantFresh bool + }{ + { + name: "authed remote passes without lookup", + authed: true, + }, + { + name: "localhost passes without lookup", + isLocal: true, + }, + { + name: "authed still passes even with sites present", + authed: true, + siteCount: 5, + }, + { + name: "unauthenticated remote on fresh server is allowed and additive", + siteCount: 0, + wantFresh: true, + }, + { + name: "unauthenticated remote with sites is rejected", + siteCount: 1, + wantReject: "unauthorized", + }, + { + name: "lookup error fails closed for unauthenticated remote", + lookupErr: lookupErr, + wantReject: "internal", + }, + { + name: "lookup error takes precedence over a zero count", + siteCount: 0, + lookupErr: lookupErr, + wantReject: "internal", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := libraryImportAuthDecision(tc.authed, tc.isLocal, tc.siteCount, tc.lookupErr) + if got.reject != tc.wantReject { + t.Errorf("reject = %q, want %q", got.reject, tc.wantReject) + } + if got.freshServer != tc.wantFresh { + t.Errorf("freshServer = %v, want %v", got.freshServer, tc.wantFresh) + } + // A rejected request must never be marked fresh (would imply an + // additive seed on a denied path). + if got.reject != "" && got.freshServer { + t.Errorf("rejected decision marked freshServer") + } + }) + } +} diff --git a/internal/info.go b/internal/info.go index 99fd54b59..9d79070e5 100644 --- a/internal/info.go +++ b/internal/info.go @@ -91,18 +91,23 @@ func RegisterInfoEndpoint(pb *pocketbase.PocketBase) error { siteCap := getCap(pb, "site_cap", "PRIMO_SITE_CAP") editorCap := getCap(pb, "editor_cap", "PRIMO_EDITOR_CAP") siteCount, _ := pb.CountRecords("sites") + // library_block_count lets the setup screen show what a fresh + // `primo deploy` just seeded ("1 site · 12 library blocks loaded") + // before the operator creates their account. + libraryBlockCount, _ := pb.CountRecords("library_symbols") return requestEvent.JSON(200, struct { - Id string `json:"id"` - Version string `json:"version"` - TelemetryEnabled bool `json:"telemetry_enabled"` - SMTPEnabled bool `json:"smtp_enabled"` - HostedMode bool `json:"hosted_mode"` - BillingURL string `json:"billing_url,omitempty"` - DevMode bool `json:"dev_mode"` - SiteCap int `json:"site_cap,omitempty"` - SiteCount int64 `json:"site_count"` - EditorCap int `json:"editor_cap,omitempty"` + Id string `json:"id"` + Version string `json:"version"` + TelemetryEnabled bool `json:"telemetry_enabled"` + SMTPEnabled bool `json:"smtp_enabled"` + HostedMode bool `json:"hosted_mode"` + BillingURL string `json:"billing_url,omitempty"` + DevMode bool `json:"dev_mode"` + SiteCap int `json:"site_cap,omitempty"` + SiteCount int64 `json:"site_count"` + LibraryBlockCount int64 `json:"library_block_count"` + EditorCap int `json:"editor_cap,omitempty"` // Domain provider + base domain let the editor pick the // connect-domain flow: "railway" runs the attach+poll flow, // "manual" shows generic DNS guidance. base_domain (if set) @@ -110,18 +115,19 @@ func RegisterInfoEndpoint(pb *pocketbase.PocketBase) error { DomainProvider string `json:"domain_provider"` BaseDomain string `json:"base_domain,omitempty"` }{ - Id: id, - Version: version, - TelemetryEnabled: false, // Analytics disabled - SMTPEnabled: smtpEnabled, - HostedMode: isHostedMode(), - BillingURL: os.Getenv("PRIMO_BILLING_URL"), - DevMode: DevMode, - SiteCap: siteCap, - SiteCount: siteCount, - EditorCap: editorCap, - DomainProvider: getDomainProvider().Name(), - BaseDomain: baseDomain(), + Id: id, + Version: version, + TelemetryEnabled: false, // Analytics disabled + SMTPEnabled: smtpEnabled, + HostedMode: isHostedMode(), + BillingURL: os.Getenv("PRIMO_BILLING_URL"), + DevMode: DevMode, + SiteCap: siteCap, + SiteCount: siteCount, + LibraryBlockCount: libraryBlockCount, + EditorCap: editorCap, + DomainProvider: getDomainProvider().Name(), + BaseDomain: baseDomain(), }) }) diff --git a/src/lib/instance.ts b/src/lib/instance.ts index 97b92d680..731505a24 100644 --- a/src/lib/instance.ts +++ b/src/lib/instance.ts @@ -10,6 +10,7 @@ export type InstanceInfo = { dev_mode: boolean site_cap?: number site_count: number + library_block_count: number editor_cap?: number } diff --git a/src/routes/setup/+page.svelte b/src/routes/setup/+page.svelte index 3342ebbb5..6d14eb1cb 100644 --- a/src/routes/setup/+page.svelte +++ b/src/routes/setup/+page.svelte @@ -3,6 +3,16 @@ import { Users } from '$lib/pocketbase/collections' import { Loader } from 'lucide-svelte' import { self } from '$lib/pocketbase/managers' + import { instance } from '$lib/instance' + + // A fresh `primo deploy` seeds sites + library before any account exists, + // so on first visit we can tell the operator what's already loaded. Falls + // back gracefully to 0 when the fields are absent (older server). + const seeded_sites = instance.site_count ?? 0 + const seeded_blocks = instance.library_block_count ?? 0 + const has_seeded_content = seeded_sites > 0 || seeded_blocks > 0 + + const count_label = (n: number, singular: string) => `${n} ${singular}${n === 1 ? '' : 's'}` let email = $state('') let password = $state('') @@ -92,7 +102,13 @@

Welcome to Primo

-

Create your admin account to get started

+

+ {#if has_seeded_content} + Your workspace is loaded. Create your admin account to start editing. + {:else} + Create your admin account to get started + {/if} +

{#if checking_setup} @@ -107,6 +123,20 @@
{/if} {:else} + {#if has_seeded_content} +
+

Already loaded on this server

+
    + {#if seeded_sites > 0} +
  • {count_label(seeded_sites, 'site')}
  • + {/if} + {#if seeded_blocks > 0} +
  • {count_label(seeded_blocks, 'library block')}
  • + {/if} +
+
+ {/if} + {#if error}
{error}
{/if} @@ -235,6 +265,41 @@ } } } + .seeded { + background-color: #2a2a2a; + border: 1px solid #444; + border-left: 2px solid #ff6b35; + border-radius: 4px; + padding: 1rem 1.25rem; + margin-bottom: 2rem; + + .seeded-label { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #b6b6b6; + margin: 0 0 0.5rem; + } + + .seeded-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.25rem; + + li { + font-size: 14px; + color: #dadada; + + &::before { + content: '✓'; + color: #4ade80; + margin-right: 0.5rem; + } + } + } + } .error { color: #f72228; margin-bottom: 1rem; From a2c032fe2789e653564799abe9d910b54d11eeb6 Mon Sep 17 00:00:00 2001 From: Matthew Morris Date: Wed, 26 Aug 2026 20:32:28 -0500 Subject: [PATCH 2/2] Require an empty library for the unauthenticated seed, not just zero sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Library import is upsert-by-name, not create-only: processLibraryImport matches existing groups/symbols by name and updates them, and re-importing a block without content.yaml clears its stale fields/entries. The library is instance-wide, so a zero-site server can still hold library records — meaning the previous "zero sites" gate let an unauthenticated caller overwrite existing blocks during the deploy window. Clearing the deletes manifest didn't prevent this; it only blocks whole-record deletion, not name-match updates. Gate the unauthenticated path on an empty library too (zero groups AND symbols), so it is genuinely create-only. Fail closed on any count error. A real first deploy has an empty library, so the intended flow is unaffected. (Raised by CodeRabbit.) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/import_library.go | 54 +++++++++++++++++++----------- internal/import_library_test.go | 58 ++++++++++++++++++++------------- 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/internal/import_library.go b/internal/import_library.go index 5fe52f139..aa3104c7d 100644 --- a/internal/import_library.go +++ b/internal/import_library.go @@ -47,21 +47,29 @@ type libraryImportAuth struct { // libraryImportAuthDecision decides whether an import-library request may // proceed. Authenticated or localhost callers always pass. An unauthenticated -// remote caller is allowed ONLY on a fresh server (zero sites), mirroring the -// bootstrap endpoint's guard so `primo deploy` can seed the library in the same -// pre-account window it seeds sites. We fail closed: if the site lookup errored -// we can't prove the server is fresh, so we reject rather than allow. +// remote caller is allowed ONLY on a fresh server (zero sites) whose library is +// also still empty, mirroring the bootstrap endpoint's guard so `primo deploy` +// can seed the library in the same pre-account window it seeds sites. +// +// The library-empty requirement matters because import is upsert-by-name, not +// create-only: processLibraryImport matches existing groups/symbols by name and +// updates them (and re-imports clear a matched block's stale fields/entries). +// The library is instance-wide, so a zero-site server can still hold library +// records; without this check an unauthenticated caller could overwrite them +// during the deploy window. Requiring an empty library makes the path genuinely +// create-only. We fail closed: if either lookup errored we can't prove the +// server is fresh, so we reject rather than allow. // // Extracted as a pure function so the security-sensitive gate is unit-testable // without an HTTP/PocketBase harness (the handler has no such test scaffold). -func libraryImportAuthDecision(authed, isLocal bool, siteCount int, lookupErr error) libraryImportAuth { +func libraryImportAuthDecision(authed, isLocal bool, siteCount, libraryCount int, lookupErr error) libraryImportAuth { if authed || isLocal { return libraryImportAuth{} } if lookupErr != nil { return libraryImportAuth{reject: "internal"} } - if siteCount > 0 { + if siteCount > 0 || libraryCount > 0 { return libraryImportAuth{reject: "unauthorized"} } return libraryImportAuth{freshServer: true} @@ -70,22 +78,30 @@ func libraryImportAuthDecision(authed, isLocal bool, siteCount int, lookupErr er func RegisterLibraryImportEndpoint(pb *pocketbase.PocketBase) error { pb.OnServe().BindFunc(func(serveEvent *core.ServeEvent) error { serveEvent.Router.POST("/api/primo/import-library", func(e *core.RequestEvent) error { - // Only look sites up when it can matter (unauthenticated remote - // caller); authed/localhost skip the query entirely. + // Only count sites/library when it can matter (unauthenticated + // remote caller); authed/localhost skip the queries entirely. The + // unauthenticated path requires BOTH zero sites and an empty + // library — see libraryImportAuthDecision. authed := e.Auth != nil isLocal := IsLocalhost(e) - siteCount := 0 + var siteCount, libraryCount int64 var lookupErr error if !authed && !isLocal { - sites, err := pb.FindAllRecords("sites") - lookupErr = err - siteCount = len(sites) + siteCount, lookupErr = pb.CountRecords("sites") + if lookupErr == nil { + var symbolCount, groupCount int64 + symbolCount, lookupErr = pb.CountRecords("library_symbols") + if lookupErr == nil { + groupCount, lookupErr = pb.CountRecords("library_symbol_groups") + } + libraryCount = symbolCount + groupCount + } } - decision := libraryImportAuthDecision(authed, isLocal, siteCount, lookupErr) + decision := libraryImportAuthDecision(authed, isLocal, int(siteCount), int(libraryCount), lookupErr) switch decision.reject { case "internal": - return e.InternalServerError("Failed to check existing sites", lookupErr) + return e.InternalServerError("Failed to check whether the server is fresh", lookupErr) case "unauthorized": return e.UnauthorizedError("Authentication required", nil) } @@ -118,11 +134,11 @@ func RegisterLibraryImportEndpoint(pb *pocketbase.PocketBase) error { } } - // An unauthenticated fresh-server seed is strictly additive: never - // honor a deletes manifest on that path. There's nothing to delete - // on a zero-site server anyway, but this keeps the unauthenticated - // surface incapable of destroying records even if the DB isn't - // actually empty (e.g. library records without a site). + // The unauthenticated fresh-server path is gated on an empty library + // (see libraryImportAuthDecision), so it only ever creates records — + // there is nothing to update or delete. Belt-and-suspenders: drop any + // deletes manifest so a caller can't smuggle deletions in even if the + // emptiness check and this import were to race. if freshServer { deletes = DeletesManifest{} } diff --git a/internal/import_library_test.go b/internal/import_library_test.go index e5d968dec..e785402bf 100644 --- a/internal/import_library_test.go +++ b/internal/import_library_test.go @@ -8,19 +8,21 @@ import ( // The import-library auth gate is security-sensitive: it opens an // unauthenticated write path, so its exact conditions are pinned here. The // gate must (a) always let authed/localhost callers through, (b) allow an -// unauthenticated remote caller ONLY on a fresh (zero-site) server, and -// (c) fail closed when the site lookup errors. +// unauthenticated remote caller ONLY on a fresh server — zero sites AND an +// empty library, so the path is genuinely create-only — and (c) fail closed +// when a lookup errors. func TestLibraryImportAuthDecision(t *testing.T) { lookupErr := errors.New("db down") cases := []struct { - name string - authed bool - isLocal bool - siteCount int - lookupErr error - wantReject string - wantFresh bool + name string + authed bool + isLocal bool + siteCount int + libraryCount int + lookupErr error + wantReject string + wantFresh bool }{ { name: "authed remote passes without lookup", @@ -31,13 +33,14 @@ func TestLibraryImportAuthDecision(t *testing.T) { isLocal: true, }, { - name: "authed still passes even with sites present", - authed: true, - siteCount: 5, + name: "authed still passes even with sites and library present", + authed: true, + siteCount: 5, + libraryCount: 12, }, { - name: "unauthenticated remote on fresh server is allowed and additive", - siteCount: 0, + name: "unauthenticated remote on fully fresh server is allowed and create-only", + // zero sites, empty library wantFresh: true, }, { @@ -46,32 +49,43 @@ func TestLibraryImportAuthDecision(t *testing.T) { wantReject: "unauthorized", }, { - name: "lookup error fails closed for unauthenticated remote", - lookupErr: lookupErr, - wantReject: "internal", + name: "unauthenticated remote with an existing library is rejected even at zero sites", + libraryCount: 1, + wantReject: "unauthorized", }, { - name: "lookup error takes precedence over a zero count", - siteCount: 0, + name: "site lookup error fails closed", lookupErr: lookupErr, wantReject: "internal", }, + { + name: "lookup error takes precedence over zero counts", + siteCount: 0, + libraryCount: 0, + lookupErr: lookupErr, + wantReject: "internal", + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := libraryImportAuthDecision(tc.authed, tc.isLocal, tc.siteCount, tc.lookupErr) + got := libraryImportAuthDecision(tc.authed, tc.isLocal, tc.siteCount, tc.libraryCount, tc.lookupErr) if got.reject != tc.wantReject { t.Errorf("reject = %q, want %q", got.reject, tc.wantReject) } if got.freshServer != tc.wantFresh { t.Errorf("freshServer = %v, want %v", got.freshServer, tc.wantFresh) } - // A rejected request must never be marked fresh (would imply an - // additive seed on a denied path). + // A rejected request must never be marked fresh (would imply a + // create-only seed on a denied path). if got.reject != "" && got.freshServer { t.Errorf("rejected decision marked freshServer") } + // A fresh decision must be strictly create-only: zero sites AND + // empty library. + if got.freshServer && (tc.siteCount > 0 || tc.libraryCount > 0) { + t.Errorf("freshServer granted with siteCount=%d libraryCount=%d", tc.siteCount, tc.libraryCount) + } }) } }