Skip to main content

Template Catalog API

The template catalog manages the set of website and work scaffolding templates available to a user. It mixes built-in templates (seeded on module boot), repositories discovered via the user's GitHub installation, and arbitrary custom templates added by repository URL. Each user has a per-kind default that drives the new-work and new-website flows.

The catalog is rendered as a gallery in the platform dashboard's Templates section.

Architecture

apps/api/src/template-catalog/
template-catalog.controller.ts # 7 REST endpoints under /api/templates*
template-catalog.module.ts # Wires controller, agent module, activity-log
dto/list-templates.dto.ts # 7 DTOs (List/Add/Update/Archive/SetDefault/Fork/Refresh)

packages/agent/src/template-catalog/
template-catalog.service.ts # Seed, discovery, CRUD, fork, refresh
...

The agent-package TemplateCatalogService owns persistence and the GitHub discovery pass (filtered to *template-suffix repositories under the user's installations). The API controller is a thin layer that adds activity-log emission on the five mutating endpoints.

Template kinds

Every endpoint is parameterised by kind:

KindPurpose
websiteTemplates for the public-facing website repository.
workTemplates for the work content repository (work scaffolding).

The kind is validated via class-validator's @IsIn(['website', 'work']) on every DTO.

Built-in seeds

TemplateCatalogService.onModuleInit() upserts two built-in templates on boot, wrapped in try/catch + warn-log so a seed failure never blocks app startup:

TemplateRequired env varNotes
ClassicalwaysThe canonical default for new websites.
MinimalWEBSITE_TEMPLATE_MINIMAL_REPOOptional; only seeded when the env var points at a <owner>/<repo> coordinate.

Built-in templates are kept in sync across reboots via findBuiltInByRepositoryCoordinates(owner, repo) so reseeding is idempotent.

REST endpoints

All endpoints sit behind the global AuthSessionGuard and resolve the user exclusively from auth.userId. Five of the seven endpoints fire-and-forget an activity-log row via .catch(() => {}) so an audit-write failure does not break the user-facing endpoint.

GET /api/templates

List templates visible to the current user for one kind.

Query: ?kind=website or ?kind=work.

Response 200:

{
"status": "success",
"kind": "website",
"defaultTemplateId": "tmpl-classic",
"templates": [
{
"id": "tmpl-classic",
"name": "Classic",
"description": "...",
"framework": "Next.js",
"previewImageUrl": "https://...",
"repositoryUrl": "https://github.com/ever-works/website-classic-template",
"branch": "main",
"originType": "standard",
"kind": "website",
"isActive": true
}
]
}

The originType field discriminates display rules in the UI:

originTypeMeaning
standardBuilt-in seed (Classic / Minimal).
forkedA fork the user created from a built-in template via this catalog.
custom_urlA custom template added via POST /api/templates/custom.

POST /api/templates/custom

Add a custom template from a GitHub repository URL.

Request body:

{
"kind": "website",
"repositoryUrl": "https://github.com/owner/my-template",
"name": "My Template",
"description": "...",
"framework": "Next.js",
"previewImageUrl": "https://...",
"branch": "main",
"betaBranch": "beta"
}

Only kind + repositoryUrl are required (the URL is validated as http or https). Defaults applied when fields are omitted:

  • branch'main'.
  • namehumanizeRepositoryName(repo) (e.g. my-templateMy Template).
  • framework → inferred from the repository contents (inferFrameworkFromRepository).
  • description / previewImageUrl / betaBranchnull.

Errors:

  • 400 Bad RequestrepositoryUrl is not a valid GitHub URL parsable by parseGitHubRepositoryUrl. Message: Only valid GitHub repository URLs are supported for custom templates.
  • 409 Conflict — duplicate. Message: You already added this template repository.

Activity log: template_added / template.added / Added <kind> template: <name> with metadata: { templateId, kind }.

PUT /api/templates/custom/:templateId

Update a custom template's editable metadata. The owner is derived from auth.userId; cross-user updates return 404.

The DTO follows three rules for each optional field:

  • undefined → preserve the existing value.
  • empty-string → set to null (clear) for nullable fields, otherwise preserve.
  • any other value → write it.

If branch changes, the service re-runs syncBranches so beta-vs-main detection stays consistent.

Activity log: template_updated / template.updated.

POST /api/templates/custom/:templateId/archive

Soft-delete a custom template via isActive=false. The service refuses the archive when the template is:

  • assigned as the default for one or more works (singular vs plural copy),
  • inheriting-default for one or more works (singular vs plural copy).

Both cases return 409 Conflict with a user-readable message naming the blocking work(s). On success the per-user preference row is removed too, so the user falls back to whatever getDefaultTemplateIdForUser resolves next.

Activity log: template_archived / template.archived.

PUT /api/templates/default

Set the default template for the current user × kind. The service verifies the template is visible to the user before writing the preference row.

Request body: { "kind": "website", "templateId": "tmpl-classic" }

Activity log: template_default_set / template.default_set.

POST /api/templates/fork

Fork a built-in (originType: 'standard') template to the user's GitHub account or an organization, then auto-set the new fork as the user's default for that kind.

Request body: { "kind": "website", "templateId": "tmpl-classic", "targetOwner": "my-org" }

Errors:

  • 404 Not Found — template is invisible to the user, or the template's kind doesn't match the request.
  • 400 Bad Request — template is not a built-in (only originType: 'standard' may be forked), or targetOwner is empty / unavailable.
  • 400 Bad RequestgitFacade.forkRepository failed (the GitHub API returned an error or the user has no GitHub connection).

Idempotency: if a matching fork already exists ((kind, userId, targetOwner, repositoryName) quadruple), the service re-adopts it as the default and returns created: false WITHOUT emitting the template.forked activity-log row. Otherwise a new row is created with seven metadata.forkedFromX audit fields and the activity log is emitted.

Activity log: template_forked / template.forked. Only emitted when created: true.

Response 200:

{
"status": "success",
"kind": "website",
"created": true,
"template": { "id": "tmpl-fork-1", "name": "...", "originType": "forked" },
"repository": { "fullName": "my-org/website-classic-template" },
"defaultTemplateId": "tmpl-fork-1"
}

POST /api/templates/refresh

Trigger a discovery pass that scans the user's GitHub installations for *template-suffix repositories. Discovery is gated by a 1-hour TTL (WEBSITE_DISCOVERY_SYNC_TTL_MS) plus a 50-page × 100-result safety cap. Repositories whose canonical IDs match a built-in are reconciled in favor of the built-in (otherwise repo.name.toLowerCase() is used). Duplicate IDs trigger a deactivation branch.

The endpoint does NOT emit an activity-log row by design (see follow-up T41 in the Spec Kit feature).

Request body: { "kind": "website" }

Response 200: identical to GET /api/templates (same envelope).

Default-template resolution

getDefaultTemplateIdForUser(kind, userId) follows a four-level fallback:

  1. The user's explicit per-kind preference row.
  2. The user's preference target only if it's still visible (not archived / deleted / cross-user). If invalid, the preference is cleaned up.
  3. The kind-specific seed default — getDefaultWebsiteTemplateId() for website, null for work.
  4. null when nothing matches.

A pending follow-up (T40) tracks the edge case where the kind-specific seed default is itself archived; today the fallback still returns it.

Configuration

Env varPurpose
WEBSITE_TEMPLATE_MINIMAL_REPOOptional <owner>/<repo> coordinate for the Minimal website seed.
WEBSITE_DISCOVERY_SYNC_TTL_MSTTL for the discovery pass. Default 1 hour. Lower for development.

Module registration

@Module({
imports: [AuthModule, AgentTemplateCatalogModule, AgentActivityLogModule],
controllers: [TemplateCatalogController]
})
export class TemplateCatalogModule {}

The agent-level AgentTemplateCatalogModule provides TemplateCatalogService; importing it from another feature module is the recommended way to consume catalog state outside this controller (e.g. onboarding-work.adapter.ts reads default-template ids when scaffolding a new work).

  • Spec Kit feature: docs/specs/features/templates-catalog/{spec,plan,tasks}.md — canonical source of truth, including the 24 functional requirements and outstanding follow-ups (T35 Postgres-container integration test, T36 e2e, T37 generalising providerId='github', T38 kind:'work' discovery convention, T39 HTTP HEAD reachability check, T40 archived-default fallback, T41 template.refreshed activity log).
  • See Activity Log for the five mutating endpoints' audit-row shape.
  • See Authentication for AuthSessionGuard semantics.