Feature Specification: Templates Catalog
Feature ID: templates-catalog
Status: Retrospective
Created: 2026-05-08
Last updated: 2026-09-26
Owner: Ever Works Team
Indexed by: the capability catalogue (AW-21) lists these template kinds with a count each under Starting points and links back to this catalog; browsing and forking stay here.
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) that are not App Blueprints (FR-5), 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 a
*templaterepository in the catalog org whose provider reports the topicever-works-app-blueprint(an App Blueprint such asever-works/cal-template), whensyncDiscoveredWebsiteTemplatesForUserruns, then it is not upserted as a website template, and an active discovered built-in row an earlier discovery saved for it is RETIRED (FR-5 c), whether or not Works use it: it stays active, so those Works keep resolving it, but it leaves every picker and is refused as a new selection (FR-5 e). - Given a retired row (FR-5 c), when I create a Work with it,
switch a Work to it, change a Work's settings to it, set it as my
default, or fork it, then the call fails with a 400 whose message
says
Template "<id>" (<owner>/<repo>) is an App Blueprint, not a website template. …; re-sending the id a Work already has (a settings save, a no-op switch) still succeeds. - Given my saved website default is a retired row (saved while it was still listed), when I create a Work WITHOUT naming a template — the Create-Work forms' "use my default" choice, a draft, or an import — then the Work is pinned to the template a user with no saved default gets (its kind's default, else the system default) and never inherits the retired row (FR-5 f); the template listing reports that system default as my default, never the unlisted row.
- Given my saved website default is a retired row, when I
switch a Work that uses another template to "use my default", or save
its settings with no website template, then the call fails with a
400 whose message says
Your default website template "<id>" (<owner>/<repo>) is an App Blueprint, …before anything is reset or saved (FR-5 f); a Work that already inherits the row, or names it, may still do so. - 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)minus retired rows (FR-5 e) mapped toTemplateCatalogItems and orderedsourceType DESC, name ASC, anddefaultTemplateIdas the default a NEW selection gets: the user's saved default unless it is retired, else the FR-23 fallback (FR-5 f), so it never names a row missing fromtemplatesbecause of retirement. - 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. Only an ACTIVE discovered row satisfies this gate: curated rows are seeded with emptymetadata. A retired App Blueprint row (FR-5 c) is active and keeps that key, but discovery writes it only once, when it retires it, so it satisfies the gate only for the hour after its retirement. When the catalog org holds no other*templaterepository the gate otherwise never passes, and a website list read re-runs discovery each time the in-process 5-minute attempt cooldown (WEBSITE_DISCOVERY_ATTEMPT_COOLDOWN_MS, per catalog org, per process) has elapsed, bounded by the 8-second deadline (WEBSITE_DISCOVERY_DEADLINE_MS). - 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())), and then: (a) MUST exclude a repository whose provider reports the App Blueprint topicever-works-app-blueprint(APP_BLUEPRINT_TOPIC): an App Blueprint such asever-works/cal-templategenerates an App Work, not a website, even though its name ends intemplate; (b) MUST apply the name rule alone when the provider does not reporttopics(the field is absent) or reports an empty list, so a provider without topic support hides nothing; (c) MUST RETIRE — never deactivate, never delete — every active discovered built-in website row for an excluded repository, found byfindAllBuiltInByRepositoryCoordinates('website', owner, name): the row's existingmetadataJSON gainsretiredReason: 'app_blueprint'andretiredAt(ISO timestamp);isActivestaystrueand no other column changes (no schema change, no migration). CuratedWEBSITE_TEMPLATESrows (matched by coordinates and by id) and custom (user-created) rows are never retired, and an already-retired row is not rewritten. A row is retired whether or not Works use it, with no usage count: the website resolver resolves a retired row exactly like any active row, so every Work naming it and every Work whose owner'swebsitedefault is the row keeps resolving it — including a Work created, switched or defaulted onto the row while discovery runs. (Deactivating instead broke those Works — the resolver only resolves active rows and a discovered id has no static config — and guarding the deactivation with a usage count left exactly that window.) A failed lookup or write is warn-logged, leaves the row as it was, is retried by the next discovery, and does not stop the website templates from being discovered; (d) MUST keep a retirement when the provider does not reporttopicsfor the repository — the (b) fallback re-upserts the row with the retirement marker carried over, since unreported topics are no evidence the repository stopped being a Blueprint — and lift it when the provider reports a topic list withoutAPP_BLUEPRINT_TOPIC(the name rule then makes it a website template again); (e) MUST keep a retired row out of every picker and refuse it as a NEW selection.listTemplatesForUser(FR-2, and through itGET /api/works/website-templatesand the refresh route) drops it. Work create, the Work settings update (updateWorkwithwebsiteTemplateId) andswitchWebsiteTemplaterefuse it, unless it is the id the Work already has (a settings save or a no-op switch on a Work already on it keeps working);setDefaultTemplateForUserandforkTemplateForUser(whose fork would become a never-retired custom template and the user's default) refuse it. Every refusal isBadRequestException({ status: 'error', message: 'Template "<id>" (<owner>/<repo>) is an App Blueprint, not a website template. Choose a website template, or create an App Work from the Blueprint instead.' }).getVisibleTemplateForUserstill returns the row, withretiredReasonset on itsTemplateCatalogItem(nullon every other row), andgetDefaultTemplateIdForUserstill answers it as a user's default, because existing references to it stay valid; (f) MUST NOT let a Work NEWLY inherit a retired row that a user saved as theirwebsitedefault before it was retired. Every Create-Work form starts on "use my default" and sends no id, and a Work storing no template inherits the saved default, which (e) keeps resolvable. So:getWebsiteTemplateIdForNewWork(userId, workKind)answersnull(store no template, inherit as before) unless the saved default is retired, and then answersgetWebsiteTemplateIdWithoutSavedDefault(workKind)— the template a user with no saved default gets (the kind's default when that template ships, else the system default; it is the website resolver's own fall-through, and the resolver spec pins the two together). Work create (no id),createDraftWorkand bothWorkImportServicecreates store that id when it is set.switchWebsiteTemplatewith no id andupdateWorkwithwebsiteTemplateId: nullrefuse, before any repository reset or save, when the saved default is retired and is not the template the Work uses now, withBadRequestException({ status: 'error', message: 'Your default website template "<id>" (<owner>/<repo>) is an App Blueprint, not a website template, so a Work cannot newly inherit it. Choose a website template for this Work, or set a website template as your default first.' }); they refuse rather than substitute a template because the switch would reset the Work's website repository from a template the user never saw named. A Work that already inherits the row, or names it, may move to inheriting it (what it resolves does not change).listTemplatesForUserreports the FR-23 fallback asdefaultTemplateId(FR-2) while the saved default is retired, andgetRetiredDefaultTemplateForUser(kind, userId)returns the retired saved default (ornull) for callers that must refuse. Works that already inherit the row are not changed. - 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.' }), and a retired row (FR-5 e) with the FR-5 eBadRequestException. On success it upserts the(userId, kind, templateId)row. - FR-16 The system MUST refuse
forkTemplateForUserwithNotFoundExceptionfor invisible / kind-mismatched templates, the FR-5 eBadRequestExceptionfor a retired row,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'. A retired row (FR-5 c) is still visible, so a saved default pointing at one is still answered: it is what the user's inheriting Works resolve. A NEW Work and a new selection never get it (FR-5 f): the listing'sdefaultTemplateIdandgetWebsiteTemplateIdForNewWorktreat it as no saved default. - 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)