Feature Specification: Templates Catalog
Feature ID: templates-catalog
Status: Retrospective
Created: 2026-05-08
Last updated: 2026-05-08
Owner: Ever Works Team
1. Overview
The templates catalog is the platform's per-user, per-kind registry of
website and work templates. It exposes seven HTTP endpoints
(GET /api/templates, POST /api/templates/custom,
PUT /api/templates/custom/:templateId,
POST /api/templates/custom/:templateId/archive,
PUT /api/templates/default, POST /api/templates/fork,
POST /api/templates/refresh) that let an authenticated user list the
templates visible to them (built-in + their own custom rows), add a
custom template from a GitHub URL, fork a built-in standard template
into their own GitHub account or organization, set a per-kind default,
update or archive a custom template, and refresh the catalog by
re-syncing GitHub-discovered built-in templates. The platform seeds the
canonical website templates on every module bootstrap (Classic always,
plus Minimal when WEBSITE_TEMPLATE_MINIMAL_REPO is set), runs an
on-demand discovery pass for any repository in
WEBSITE_TEMPLATE_CATALOG_ORG (default ever-works) whose name ends in
*template — gated by a 1-hour TTL so back-to-back list calls don't
re-hit GitHub — and emits a fire-and-forget activity-log entry for the
five mutating endpoints (add / update / archive /
set default / fork-when-newly-created).
2. User Scenarios
2.1 Primary scenarios
- Given I am an authenticated user, when I call
GET /api/templates?kind=website, then the response is{ status: 'success', kind: 'website', defaultTemplateId, templates }wheretemplatesis the union of(sourceType='built_in' AND isActive=true)rows and(sourceType='custom' AND ownerUserId=me AND isActive=true)rows, sorted bysourceType DESC(custom first) thenname ASC. - Given the website-template GitHub catalog
(
WEBSITE_TEMPLATE_CATALOG_ORG, defaultever-works) was last re-synced more than 1 hour ago (theWEBSITE_DISCOVERY_SYNC_TTL_MSwindow), when I callGET /api/templates?kind=website, then the controller callslistTemplatesForUser, which callssyncDiscoveredWebsiteTemplatesIfStale(userId)BEFORE the read; that walks GitHub up to 50 pages × 100 repos, filters to*template-suffixed repository names (case-insensitive trailingtemplate), and upserts each one with the canonical id (existing built-in by(repositoryOwner, repositoryName)wins; otherwiserepository.name.toLowerCase()). - Given I add a custom template, when I call
POST /api/templates/customwith{ kind: 'website', repositoryUrl: 'https://github.com/me/my-template', name: 'My Template' }, then the serviceparseGitHubRepositoryUrls the URL, refuses non-GitHub URLs withBadRequestException('Only valid GitHub repository URLs are supported for custom templates.'), refuses duplicate adds withConflictException('You already added this template repository.'), and otherwise upserts a row keyed byid = 'custom-<uuid>'withsourceType: 'custom',ownerUserId: me, normalisedbranch: 'main'(when omitted),syncBranches: [branch](when omitted), and a humanisedname(humanizeRepositoryName(repo)) fallback. Atemplate.addedactivity-log entry is emitted fire-and-forget. - Given I fork a standard template, when I call
POST /api/templates/forkwith{ kind: 'website', templateId: 'classic', targetOwner: 'me' }, then the service rejects withNotFoundExceptionif the template is not visible to me, withBadRequestException('Only standard templates can be forked.')if the template is already custom, withBadRequestException('A target account or organization is required.')on emptytargetOwner, and withBadRequestException('The selected fork target is not available for this GitHub connection.')if the resolved login is neither my GitHub user nor a connected organization. On the happy path it callsgitFacade.forkRepository, upserts acustom-<uuid>row that inherits the source template'sname/description/framework/previewImageUrl/syncBranches/betaBranch, setsmetadata.forkedFromTemplateIdplus three audit fields, and sets the fork as the user's default for that kind. - Given I have already forked a standard template into the same
target before, when I call
POST /api/templates/forkagain with the same{ kind, templateId, targetOwner }, then the service short-circuits — it does NOT re-callgitFacade.forkRepository— re-adopts the existing custom row as my default, returns{ defaultTemplateId, template, repository, created: false }, and emits NOtemplate.forkedactivity-log entry (the gate isif (result.created)). - Given I set a default, when I call
PUT /api/templates/defaultwith{ kind: 'website', templateId: '<id>' }, then the service refuses withNotFoundExceptionwhen the template is not visible to me or the kind does not match, otherwise upserts auser_template_preferencesrow ((userId, kind)unique) and emits atemplate.default_setactivity-log entry. The nextGET /api/templates?kind=websitereflects the newdefaultTemplateId. - Given I update a custom template, when I call
PUT /api/templates/custom/:templateIdwith{ kind: 'website', name, description, framework, previewImageUrl, branch, betaBranch }, then the service refuses withNotFoundException('Custom template not found for this user and kind.')when the row is not active, not custom, not mine, or does not matchkind. On the happy path each field follows the "undefined → preserve, empty-string → null/preserve" rules pinned in §3 FR-9 / FR-10, the row is updated, and atemplate.updatedactivity-log entry is emitted. - Given I archive a custom template, when I call
POST /api/templates/custom/:templateId/archivewith{ kind: 'website' }, then the service refuses withNotFoundExceptionwhen the row is not active / mine / matching kind, refuses withConflictException('This template is still assigned to N work(s). Reassign … before archiving the template.')when the template is currently assigned to one or more works (singular vs plural copy), additionally refuses withConflictException('This template is your current default and N work(s) inherit it. Reassign those works or change your default template before archiving.')when it is the user's default AND any works are inheriting it (only checked when not currently assigned to a specific work), then setsisActive = falseand removes the matchinguser_template_preferencesrow before emitting atemplate.archivedactivity-log entry. - Given I want a fresh catalog, when I call
POST /api/templates/refreshwith{ kind: 'website' }, then the service unconditionally re-syncs GitHub-discovered website templates (no TTL check), then returns the list aslistTemplatesForUserwould.
2.2 Edge cases & failures
- Given the GitHub discovery call fails (rate limit, connectivity,
invalid token), when
syncDiscoveredWebsiteTemplatesForUserruns, then the error is caught and logged at warn level (Failed to sync discovered website templates for user <id>: <msg>) — the user-facing list call still succeeds and falls back to the already-persisted templates. - Given the catalog walk hits 50 pages without exhausting the
pageRepositories.length < perPageexit, when the loop ends, then the service logs at warn level (Template discovery for org <owner> hit the 50-page safety cap; some repositories may be missing from the catalog.). - Given a discovered repository would collide with an existing row
whose id matches
repository.name.toLowerCase()but does NOT match(repositoryOwner, repositoryName), whensyncDiscoveredWebsiteTemplatesForUserprojects that repository, then the row is SKIPPED with a warn log (Skipping discovered template "<fullName>" because id "<discoveredId>" is already used by <owner>/<name>.). - Given the canonical built-in template id (e.g.
classic) differs from the discovered id (e.g.directory-web-template), whensyncDiscoveredWebsiteTemplatesForUserupserts the canonical row, then any duplicate active row keyed by the discovered id that carries the same coordinates is deactivated (isActive: false) so the user only sees one entry. - Given the user has no
user_template_preferencesrow for a given kind, whengetDefaultTemplateIdForUser('website', userId)runs, then it falls back togetDefaultWebsiteTemplateId()(env-driven viaWEBSITE_TEMPLATE_DEFAULT_ID, fallback'classic'); forkind: 'work', it returnsnull(no fallback). - Given
seedBuiltInTemplatesthrows duringonModuleInit, when the module boots, then the service catches and warns (Failed to seed built-in templates during startup: <msg>); the module finishes booting. Subsequent calls re-attempt the seed on-demand (via the discovery pass forwebsitekind). - Given I add a custom template with the same canonical
repositoryUrlas an already-active row I own, whenaddCustomTemplateruns, then it short-circuits withConflictExceptionBEFORE the upsert. - Given activity-log emission fails (DB error), when any
mutating endpoint completes, then the controller's
.catch(() => {})swallows the rejection so the user-facing call still returns 200.
3. Functional Requirements
- FR-1 The system MUST seed all built-in website templates on
SubscriptionService-styleonModuleInit, idempotently, viaTemplateRepository.upsert, sourced fromlistWebsiteTemplates()(Classicis always present;Minimalonly whenWEBSITE_TEMPLATE_MINIMAL_REPOis set). - FR-2 The system MUST expose
GET /api/templates?kind=<website|work>returning{ status: 'success', kind, defaultTemplateId, templates }withtemplatesasfindVisibleByKind(kind, userId)mapped toTemplateCatalogItems and orderedsourceType DESC, name ASC. - FR-3 The system MUST run
syncDiscoveredWebsiteTemplatesIfStale(userId)BEFORE the read onGET /api/templates?kind=websitewhen no built-in website template has anupdatedAt >= now() - 1hANDmetadata.discoveredFromOrganization = catalogOwner. - FR-4 The system MUST cap the GitHub discovery walk at 50 pages × 100 repositories per page, logging a warn line when the cap is hit.
- FR-5 The system MUST filter discovered repositories to those whose
name ends in
template(case-insensitive trailing match), usingisStandardTemplateRepository(/template$/i.test(name.trim())). - FR-6 The system MUST resolve discovered template ids by:
(a)
findBuiltInByRepositoryCoordinates(kind, owner, name)— if a canonical row exists, reuse its id; otherwise (b)repository.name.toLowerCase(). The canonical id wins; the discovered id is deactivated when it duplicates the canonical row's coordinates. - FR-7 The system MUST refuse non-GitHub URLs in
addCustomTemplatewithBadRequestException({ status: 'error', message: 'Only valid GitHub repository URLs are supported for custom templates.' })(usesparseGitHubRepositoryUrlfrom@ever-works/contracts). - FR-8 The system MUST refuse duplicate
addCustomTemplatecalls by the same user against the sameparseGitHubRepositoryUrl-canonical URL withConflictException({ status: 'error', message: 'You already added this template repository.' }). - FR-9 The system MUST default
branchto'main'andsyncBranchesto[branch]when those fields are omitted fromaddCustomTemplate's body.namedefaults tohumanizeRepositoryName(repository.repo).frameworkdefaults toinferFrameworkFromRepository(repository.repo)('Astro'/'Next.js'/null). - FR-10 The system MUST honour the "undefined → preserve, empty
string → null/preserve" semantics for every optional field of
updateCustomTemplateForUser:nameempty-string preserves the existing value,descriptionempty-string clears tonull,frameworkempty-string clears tonull,previewImageUrlempty-string clears tonull,betaBranchempty-string clears tonull,branchempty-string preserves the existing value. Whenbranchis changed,syncBranchesof length 1 is rewritten to[newBranch]; otherwise the existing branch entry is replaced in-place insidesyncBranches. - FR-11 The system MUST refuse
updateCustomTemplateForUserandarchiveCustomTemplateForUserwhen the row is not(active AND custom AND owned-by-me AND kind-matching)withNotFoundException({ status: 'error', message: 'Custom template not found for this user and kind.' }). - FR-12 The system MUST refuse archive when the template is
currently assigned to one or more works
(`workRepository.countByUserAndWebsiteTemplateId(userId, templateId)
0. for
kind: 'website', with singular vs. plural copy ('1 work. … the template'vs'<N> works. … the template'). - FR-13 The system MUST refuse archive when the template is the
user's current default AND inheriting works exist
(`workRepository.countByUserAndInheritedWebsiteTemplateSelection(userId)
0`) — only checked AFTER the per-template usage check passed.
- FR-14 The system MUST set
isActive = false(soft delete) on archive and remove the matchinguser_template_preferences (userId, kind, templateId)row. - FR-15 The system MUST refuse
setDefaultTemplateForUserwhen the template is not visible to the user orkinddoes not match withNotFoundException({ status: 'error', message: 'Template not found for this user and kind.' }). On success it upserts the(userId, kind, templateId)row. - FR-16 The system MUST refuse
forkTemplateForUserwithNotFoundExceptionfor invisible / kind-mismatched templates,BadRequestException('Only standard templates can be forked.')for custom templates,BadRequestException('A target account or organization is required.')for emptytargetOwner(after.trim()), andBadRequestException('The selected fork target is not available for this GitHub connection.')when the target is neither the user's GitHub login nor a connected organization (case-insensitive match). - FR-17 The system MUST short-circuit
forkTemplateForUserwhenfindOwnedCustomByRepositoryCoordinates(kind, userId, targetOwner, template.repositoryName)returns a row: re-adopt it as the default viauserTemplatePreferenceRepository.upsertDefault, return{ defaultTemplateId, template, repository, created: false }, and do NOT callgitFacade.forkRepository. - FR-18 The system MUST refuse a failed
gitFacade.forkRepository(returns falsy) withBadRequestException('Forking the selected template failed.'). - FR-19 The system MUST upsert the forked-template row with
id: 'custom-<uuid>',sourceType: 'custom',ownerUserId: userId, inheritedname/description/framework/previewImageUrl/syncBranches/betaBranchfrom the source template, and metadata{ forkedFromTemplateId, forkedFromRepositoryUrl, forkedFromOwner, forkedFromRepositoryName, forkTargetType: 'personal'|'organization' }. - FR-20 The system MUST set the fork as the user's default for that
kind via
userTemplatePreferenceRepository.upsertDefault. - FR-21 The system MUST emit fire-and-forget activity-log entries
via
activityLogService.log({ userId, actionType, action, status, summary, metadata }).catch(() => {})for the five mutating endpoints:template.added,template.updated,template.archived,template.default_set,template.forked(the last only whenresult.created === true). - FR-22 The system MUST NOT emit any activity-log entry for
listTemplatesorrefreshTemplates(read endpoints). - FR-23 The system MUST resolve the user's default template id by
consulting
user_template_preferencesfirst; if the preference's template is not visible (archived / kind mismatch / not in the user's visible set), fall back togetDefaultWebsiteTemplateId()forkind: 'website'andnullforkind: 'work'. - FR-24 The system MUST attach
originTypeto everyTemplateCatalogItem:'standard'forsourceType='built_in','forked'whenmetadata.forkedFromTemplateIdis set,'custom_url'otherwise.
4. Non-Functional Requirements
- Performance:
GET /api/templates?kind=websiteis gated by a 1-hour TTL on the discovery pass; in steady state it is two DB reads (findVisibleByKind+getDefaultTemplateIdForUser). After the TTL expires, the next call walks GitHub up to 50 × 100 repositories before responding — operators on a large catalog should expect multi-second latency for this single warm-cache call. - Reliability: Built-in seed runs on every boot but is wrapped in
a try/catch in
onModuleInit; a seed failure does NOT crash the module (Failed to seed built-in templates during startupwarn log). Discovery also has its own try/catch with warn log; the user-facing list still resolves on discovery failure. - Security & privacy: All endpoints sit behind the global
AuthSessionGuard(no@Public()); per-row visibility is enforced viafindVisibleByKind(only owncustom+ everybuilt_in).addCustomTemplate/forkTemplateForUseraccept arbitrary GitHub URLs butparseGitHubRepositoryUrlrejects non-GitHub hosts. Activity-log entries recordtemplateId/kindonly — no token / repository content material. - Observability:
template.added/template.updated/template.archived/template.default_set/template.forkedactions land inactivity_logs. Three warn-level log lines surface in normal operation: seed failure, discovery failure, 50-page cap hit, id-collision skip. - Compatibility:
TemplateKindis currently'website' | 'work'; adding a new kind means extending the union and theTEMPLATE_KINDSconst inlist-templates.dto.ts. The custom-id scheme (custom-<uuid>) is forward-compatible — built-in ids collide only when the discovery pass picks up a repository whose lowercased name matches a canonical built-in id.
5. Key Entities & Domain Concepts
| Entity / concept | Description |
|---|---|
Template | Single registry row keyed by id (varchar PK, 120). Stores kind, sourceType (built_in / custom), optional ownerUserId, name, description, framework, previewImageUrl, repositoryUrl, repositoryOwner, repositoryName, branch (default main), syncBranches[], betaBranch, isActive, metadata JSON. |
UserTemplatePreference | Per-user, per-kind default selection — row keyed by (userId, kind) unique. Holds templateId pointing to the chosen Template.id. |
TemplateCatalogItem | DTO returned by every list / mutation: { id, kind, sourceType, originType, name, description, framework, previewImageUrl, repositoryUrl, repositoryOwner, repositoryName, branch, syncBranches[], betaBranch, isActive, isDefault, ownerUserId }. |
TemplateKind | Closed union: 'website' (web-output template) or 'work' (work-data template). Currently only the website kind has discovery / Minimal / Classic seeds. |
TemplateSourceType | Closed union: 'built_in' (seed-or-discovery owned) or 'custom' (user-owned, possibly forked). |
originType | Computed at projection time: 'standard' (built-in), 'forked' (custom + metadata.forkedFromTemplateId), 'custom_url' (custom otherwise). |
WebsiteTemplateConfig | Hard-coded per-deploy structure consumed by seedBuiltInTemplates: { id, name, description, owner, repo, branch, syncBranches[], betaBranch }. Today: Classic always, plus Minimal when WEBSITE_TEMPLATE_MINIMAL_REPO is set. |
getDefaultWebsiteTemplateId() | Resolves the env-driven default (WEBSITE_TEMPLATE_DEFAULT_ID, fallback 'classic') only when the resolved id matches a configured template; otherwise 'classic'. |
6. Out of Scope
- The website-generator's runtime template execution (rendering, build,
deploy) — covered in
features/website-generator/spec. - The
WebsiteTemplateSchedulerServicecron job that updatesWork.websiteTemplateLastCommitand tracks template-update notifications — covered inWebsiteTemplateSchedulerService(a scheduler inapps/api/src/works/tasks/). - Switching a work's website template (
switchWebsiteTemplateinworks.controller.ts) — covered infeatures/website-generator/spec. - Email / Handlebars templates under
apps/api/src/templates/*.hbs— unrelated to the templates catalog despite the directory name overlap; seefeatures/mail-providers/spec. - Template marketplaces / publishing flows — out of scope; the catalog is per-user only today.
7. Acceptance Criteria
-
GET /api/templates?kind=websitereturns the union of built-in + own-custom rows, ordered bysourceType DESC, name ASC, with the correctdefaultTemplateIdfromuser_template_preferences(fallback togetDefaultWebsiteTemplateId()when no preference). -
GET /api/templates?kind=websitetriggers a discovery pass ONLY when no built-in website template was updated in the last hour AND itsmetadata.discoveredFromOrganizationmatchescatalogOwner. -
POST /api/templates/customrejects non-GitHub URLs and duplicate adds with the exact 400 / 409 messages. -
POST /api/templates/custompopulatesbranch = 'main'andsyncBranches = ['main']when omitted; populatesnamefromhumanizeRepositoryName(repo)when omitted; populatesframeworkfrominferFrameworkFromRepository(repo)when omitted. -
PUT /api/templates/custom/:templateIdhonours the "undefined → preserve, empty-string → null/preserve" rules per FR-10, includingsyncBranchesrewrite whenbranchchanges. -
POST /api/templates/custom/:templateId/archiverejects with 409 + singular vs. plural copy when works currently use the template, rejects with 409 + the "current default … N work(s) inherit" copy when applicable, and on success setsisActive = falseAND removes the matching preference row. -
PUT /api/templates/defaultrejects invisible / kind-mismatched templates with 404 and emits atemplate.default_setactivity-log entry on success. -
POST /api/templates/forkrejects each error class with the exact pinned message; the happy path forks the repository, upserts the row with the sevenmetadata.forkedFromXaudit fields, and sets the fork as the user's default. -
POST /api/templates/forkshort-circuits when an existing fork row matches(kind, userId, targetOwner, repositoryName)and does NOT callgitFacade.forkRepositoryagain, returnscreated: false, and emits NOtemplate.forkedactivity-log entry. -
POST /api/templates/refreshre-syncs unconditionally (no TTL gate) and returns the catalog aslistTemplatesForUserwould. - All five mutating endpoints emit fire-and-forget activity-log
entries with
.catch(() => {}); activity-log failures NEVER propagate to the user. -
seedBuiltInTemplatesis idempotent (upsertwithidconflict path), andonModuleInitswallows seed failures with a warn log. - All functional requirements have at least one passing unit or e2e test.
8. Open Questions
[NEEDS CLARIFICATION: OQ-1 — The fork flow hard-codesproviderId = 'github'. When a non-GitHub git provider lands (GitLab, Bitbucket), the flow needs a per-templateproviderIdfield on the source template row, not a literal.][NEEDS CLARIFICATION: OQ-2 — Thekind: 'work'discovery / seed path is empty (onlywebsitehas built-in templates today). When work-template content lands, do we follow the same*templateGitHub-name suffix convention or a different one (e.g.*work-template)?][NEEDS CLARIFICATION: OQ-3 —addCustomTemplatedoes NOT do an HTTP HEAD onrepositoryUrlto validate that the repository actually exists / is accessible. A user can register a template pointing at a private repo they cannot read, then fail downstream when the website-generator tries to clone. Should the catalog do an early reachability check?][NEEDS CLARIFICATION: OQ-4 —getDefaultTemplateIdForUserfalls back togetDefaultWebsiteTemplateId()even when the user has explicitly archived the seed default. Today this means the user's UI keeps showingclassicas default even after they archive it via discovery. Worth a follow-up to skip the fallback when the resolved id is no longer active.][NEEDS CLARIFICATION: OQ-5 — There is no controller-level activity-log emission fortemplate.refreshed. Should refreshes be logged for audit? They mutate persisted rows on a successful discovery pass.]
9. Constitution Gates
- Plugin-first if introducing an external integration (Principle I) — N/A: GitHub access flows through the existing
GitFacadeServiceplugin abstraction. - Capability-driven resolution if touching cross-plugin behaviour (Principle II) —
gitFacade.forkRepository/getOrganizations/listRepositoriesuse thegit-providercapability. - Source-of-truth repos preserved (Principle III) — templates live in user-owned GitHub repos; the catalog only persists pointers + display metadata.
- Long-running work via Trigger.dev (Principle IV) — N/A: discovery is on-demand and gated by a 1-hour TTL.
- Schema changes ship as forward-only migrations (Principle V) —
templatesanduser_template_preferencesare additive; newTemplateKindvalues extend the union without a migration. - Tests accompany the change (Principle VI) —
template-catalog.controller.spec.ts(apps/api) +template-catalog.service.spec.ts(packages/agent) cover the surface. - Secrets handled per
x-secretrules (Principle VII) — GitHub access tokens flow throughGitFacadeService.getAccessToken({ userId, providerId }); no token material lands intemplates.metadata. - Plugin counts touch the canonical doc only (Principle VIII) — N/A.
- Behaviour-first — no implementation in this spec (Principle IX) — implementation lives in
plan.md. - Backwards-compatible API/SDK/schema changes (Principle X) — endpoints are additive; the
originTypefield is computed, not stored.
10. References
- Source:
apps/api/src/template-catalog/template-catalog.controller.tsapps/api/src/template-catalog/template-catalog.module.tsapps/api/src/template-catalog/dto/list-templates.dto.tspackages/agent/src/template-catalog/template-catalog.service.tspackages/agent/src/template-catalog/template-catalog.module.tspackages/agent/src/entities/template.entity.tspackages/agent/src/entities/user-template-preference.entity.tspackages/agent/src/database/repositories/template.repository.tspackages/agent/src/database/repositories/user-template-preference.repository.tspackages/agent/src/generators/website-generator/config/website-template.config.ts
- Related features:
- User-facing docs:
docs/features/website-templates.md- PR #459 (
templates-cataloglanding PR)