Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
301 changes: 301 additions & 0 deletions .cursor/rules/bff-typescript-cdk-best-practices.mdc

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions .cursor/rules/recover-from-merged-branch.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
description: Stash-and-restart sequence to use when the current branch's PR already merged into main
alwaysApply: false
---

# Recover from a merged branch

Use this when `sync-before-code-changes` found that the current branch's own work
already shipped (its PR merged), so follow-up work needs a fresh branch off the
latest `main`.

**Precondition:** no merge or rebase in progress (`git rev-parse --verify MERGE_HEAD`
fails and `.git/rebase-merge` is absent). If one exists, stop and ask the user.

1. **Confirm it merged:**
`gh pr list --state merged --head <current-branch> --json number,mergedAt,mergeCommit`.
A hit means the content is in `main` under a (possibly squashed) different commit.
2. **Save uncommitted work:** `git stash -u` (includes untracked files).
3. **Fast-forward `main` without checking it out:** `git fetch . origin/main:main`
from the current (non-main) branch.
- Git refuses to fetch into the *checked-out* branch, so do this **before**
switching to `main`. If you are already on `main`, use
`git merge --ff-only origin/main` instead.
4. **Branch fresh:** `git checkout -b <new-branch-name> main`.
5. **Reapply with `git stash apply`, not `pop`.** Keep the stash entry until you
have confirmed the reapplied changes are still needed and correct. Some of what
was stashed may now be redundant with what already merged.
6. Once verified, drop the stash entry (`git stash drop`).
32 changes: 24 additions & 8 deletions .cursor/rules/sync-before-code-changes.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,33 @@ alwaysApply: true

# Sync before code changes

Work sometimes gets committed, pushed, and merged to `main` outside the current
session — a previous session, a teammate, a direct push — so local state can be
stale in ways that aren't obvious from `git status` alone.

Before **any** file edit, commit, or new branch work:

1. `git fetch origin`
2. Compare the **current branch** to its upstream and to `origin/main`:
2. Compare local `main` with `origin/main`: `git log main..origin/main --oneline`
3. Compare the **current branch**:
- `git status -sb`
- `git log HEAD..origin/main --oneline` (are you behind main?)
- `git log origin/main..HEAD --oneline` (unpushed / unmerged work?)
3. If this branch has (or had) an open PR, check merge state **before** stacking more commits:
- `gh pr list --head $(git branch --show-current) --state all --json number,state,mergedAt`
- If `MERGED`: do **not** keep committing on this branch. Follow `CLAUDE.md` — stash if needed, fast-forward `main`, start a **new** branch for follow-up work.
- If it is **not** merged and `git log origin/main..HEAD` is non-empty: **keep this branch**. Rebase onto `origin/main` if behind; do not open a second branch for the same line of work.
4. If `git rev-parse --verify MERGE_HEAD` or `.git/rebase-merge` exists, finish or abort that operation first; do not stash over an in-progress merge/rebase without asking the user.
- `git log origin/main..HEAD --oneline` (unpushed or unmerged work?)
4. Check whether the branch's PR already merged:
`gh pr list --head <branch> --state all --json number,state,mergedAt,mergeCommit`
5. Decide:
- **PR merged** → stop committing on this branch. Its content is already in
`main` under a (possibly squashed) different commit. Follow the
`recover-from-merged-branch` rule.
- **PR not merged, and the branch has commits not in `origin/main`** → keep
working on this branch, and rebase if it is behind. Do not start a second
branch for the same work.
- **`origin/main` moved, but this branch's PR was not merged** (genuinely
unrelated upstream work) → surface that to the user. Do not act on it
unilaterally.
6. **Merge/rebase guard.** If `git rev-parse --verify MERGE_HEAD` succeeds or
`.git/rebase-merge` exists, work to reconcile this exact situation is already
underway. Finish or abort it first, and ask the user how they want to proceed
rather than stashing over it.

Only after the branch is aligned with intent (rebased on current `main` or confirmed still the right feature branch) should you edit files.
Only edit files once the branch is aligned with intent.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ jobs:

- uses: actions/setup-node@v7
with:
node-version: 26
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 26
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile
Expand Down Expand Up @@ -58,7 +58,7 @@ jobs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 26
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile

Expand Down Expand Up @@ -104,7 +104,7 @@ jobs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 26
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile

Expand Down Expand Up @@ -185,7 +185,7 @@ jobs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 26
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile

Expand Down Expand Up @@ -245,7 +245,7 @@ jobs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 26
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile

Expand Down Expand Up @@ -327,7 +327,7 @@ jobs:
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 26
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile

Expand Down
4 changes: 4 additions & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Record exact versions when adding dependencies (`add x` writes "1.2.3", not "^1.2.3").
# pnpm 11 ignores this file for save-exact and reads `saveExact` from
# pnpm-workspace.yaml instead (both are set). npm and older pnpm read this one.
save-exact=true
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Agent instructions

**The source of truth for agent instructions in this repo is [`.cursor/rules/`](.cursor/rules/).**
Read the rules there before making changes. Rules with `alwaysApply: true` always
apply; the others apply when their `globs` match the files you are touching, or when
their `description` fits the task.

This file and `CLAUDE.md` are pointers only. Do not add rules here. Add or change
them in `.cursor/rules/`, so there is exactly one place to keep current.

| Rule | Applies | Covers |
| --- | --- | --- |
| [`sync-before-code-changes`](.cursor/rules/sync-before-code-changes.mdc) | always | Fetch origin, compare `main` and the current branch, check whether the branch's PR merged, and the merge/rebase guard |
| [`recover-from-merged-branch`](.cursor/rules/recover-from-merged-branch.mdc) | on request | The stash-and-restart sequence when a branch's PR already merged |
| [`bff-typescript-cdk-best-practices`](.cursor/rules/bff-typescript-cdk-best-practices.mdc) | globs | Repo layout, NestJS BFF on Lambda, validation, config and secrets, AWS CDK conventions, testing, CI/CD |

Also in this repo:

- [`.claude/skills/`](.claude/skills/) holds Claude Code task playbooks (adding an API
endpoint, an infra resource, or a thing type). They follow the conventions in the rules above.
- [`docs/`](docs/) holds human-facing architecture, data model, CI/CD and infra notes.
- The sibling library repo `../mycota` publishes the `@bubltec/mycota-*` packages this app
consumes, and has its own `.cursor/rules/`.
41 changes: 7 additions & 34 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,12 @@
# Working in this repo

## Check for upstream changes before starting work
Agent instructions live in [`.cursor/rules/`](.cursor/rules/), the single source of
truth (see [AGENTS.md](AGENTS.md) for the index). Do not add rules to this file.

Before making any changes, run `git fetch origin` and compare local `main`
against `origin/main` (`git log main..origin/main --oneline`). Work
sometimes gets committed, pushed, and merged to `main` outside the current
session — a previous session, a teammate, a direct push — so local state
can be stale in ways that aren't obvious from `git status` alone.
The always-apply rules are imported here so Claude Code loads them every session:

Also check the **current branch** before editing: `git log HEAD..origin/main
--oneline` and whether its PR is already merged (`gh pr list --head
<branch> --state all`). If the PR merged, stop committing on that branch —
rebase or branch fresh from `origin/main` for follow-ups (see step 2 below).
If the PR is **not** merged and the branch already has commits not in
`origin/main`, keep working on that branch (rebase if behind); do not start
a second branch for the same work.
@.cursor/rules/sync-before-code-changes.mdc
@.cursor/rules/recover-from-merged-branch.mdc

If `origin/main` has commits not in local `main`:

1. Check whether the **current branch's own work** already shipped —
`gh pr list --state merged --head <current-branch> --json number,mergedAt,mergeCommit`.
If a merged PR shows up for this exact branch, its content is already in
`main` under a (possibly squashed) different commit.
2. If so: `git stash -u` (uncommitted work, including untracked files),
`git checkout main`, `git fetch . origin/main:main` to fast-forward,
`git checkout -b <new-branch-name>` for whatever comes next, then
`git stash apply` (not `pop` — keep the stash entry until you've
confirmed the reapplied changes are actually still needed/correct, since
some of what was stashed may now be redundant with what already merged).
3. If `origin/main` moved but the *current* branch's PR was **not** merged
(i.e., it's genuinely unrelated upstream work), just surface that to the
user — don't act on it unilaterally.

Only do the stash-and-restart sequence when there's no merge/rebase already
in progress (`git rev-parse --verify MERGE_HEAD` / `.git/rebase-merge` both
absent) — if one exists, that means work to reconcile this exact situation
is already underway; ask the user how they want to proceed instead of
stashing over it.
Read `.cursor/rules/bff-typescript-cdk-best-practices.mdc` before changing the BFF,
shared packages, infrastructure, tests or CI.
4 changes: 3 additions & 1 deletion apps/bff/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
# until AWS GA (~Nov 2026); it's still Node 26.
FROM public.ecr.aws/lambda/nodejs:26-preview

COPY dist/lambda.js ${LAMBDA_TASK_ROOT}/
# The .map is what lets NODE_OPTIONS=--enable-source-maps (set in api-stack.ts)
# turn minified stack traces back into real file:line.
COPY dist/lambda.js dist/lambda.js.map ${LAMBDA_TASK_ROOT}/

CMD ["lambda.handler"]
68 changes: 34 additions & 34 deletions apps/bff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,42 @@
"test": "vitest run"
},
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "^3.716.0",
"@aws-sdk/client-dynamodb": "^3.716.0",
"@aws-sdk/client-ses": "^3.716.0",
"@aws-sdk/client-ssm": "^3.716.0",
"@aws-sdk/lib-dynamodb": "^3.716.0",
"@aws-sdk/client-bedrock-runtime": "3.1136.0",
"@aws-sdk/client-dynamodb": "3.1136.0",
"@aws-sdk/client-ses": "3.1136.0",
"@aws-sdk/client-ssm": "3.1136.0",
"@aws-sdk/lib-dynamodb": "3.1136.0",
"@btfp/shared-types": "workspace:*",
"@bubltec/mycota-auth": "0.6.0",
"@bubltec/mycota-dynamo": "0.6.0",
"@bubltec/mycota-professional-verification": "0.6.0",
"@fastify/aws-lambda": "^4.1.0",
"@fastify/cookie": "^9.4.0",
"@nestjs/common": "^10.4.0",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.4.0",
"@nestjs/swagger": "^8.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fastify": "^4.28.1",
"fuse.js": "^7.0.0",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"@bubltec/mycota-auth": "1.0.0",
"@bubltec/mycota-dynamo": "1.0.0",
"@bubltec/mycota-professional-verification": "1.0.0",
"@fastify/aws-lambda": "6.4.1",
"@fastify/cookie": "11.1.2",
"@nestjs/common": "12.0.3",
"@nestjs/config": "12.0.0",
"@nestjs/core": "12.0.3",
"@nestjs/jwt": "12.0.2",
"@nestjs/passport": "12.0.0",
"@nestjs/platform-fastify": "12.0.3",
"@nestjs/swagger": "12.0.1",
"class-transformer": "0.5.1",
"class-validator": "0.15.1",
"fastify": "5.12.5",
"fuse.js": "7.5.0",
"passport": "0.7.0",
"passport-github2": "0.1.12",
"passport-google-oauth20": "2.0.0",
"reflect-metadata": "0.2.2",
"rxjs": "7.8.2"
},
"devDependencies": {
"aws-sdk-client-mock": "^4.1.0",
"@types/node": "^26.6.1",
"@types/passport-github2": "^1.2.9",
"@types/passport-google-oauth20": "^2.0.16",
"esbuild": "^0.24.2",
"tsc-watch": "^7.2.1",
"typescript": "^5.8.0",
"vitest": "^2.1.0"
"@types/node": "26.6.2",
"@types/passport-github2": "1.2.9",
"@types/passport-google-oauth20": "2.0.17",
"aws-sdk-client-mock": "4.1.0",
"esbuild": "0.28.2",
"tsc-watch": "7.2.1",
"typescript": "7.0.2",
"vitest": "5.0.1"
}
}
8 changes: 8 additions & 0 deletions apps/bff/scripts/build-lambda.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,16 @@ await build({
// fallback is unreachable at runtime.
'class-transformer/storage',
],
// @nestjs/swagger 12 is ESM-first and runs `createRequire(import.meta.url)` at
// load time. esbuild turns import.meta into an empty object in CJS output, so
// that call throws and the whole Lambda fails to start. Give it a real value.
banner: { js: "const __importMetaUrl = require('node:url').pathToFileURL(__filename).href;" },
define: { 'import.meta.url': '__importMetaUrl' },
sourcemap: true,
minify: true,
// Minification mangles class names, which are what Nest prints as its log
// context ("[e]" instead of "[SearchService]"). Keep them; the size cost is small.
keepNames: true,
logLevel: 'info',
});

Expand Down
2 changes: 1 addition & 1 deletion apps/bff/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { BffDynamoModule } from './dynamo/bff-dynamo.module.js';
import { MycotaAuthModule } from '@bubltec/mycota-auth';
import { ProfessionalVerificationModule } from '@bubltec/mycota-professional-verification';
import { ProfessionalVerificationModule } from './professional-verification/professional-verification.module.js';
import { PetTypesModule } from './pet-types/pet-types.module.js';
import { BreedsModule } from './breeds/breeds.module.js';
import { ThingTypesModule } from './thing-types/thing-types.module.js';
Expand Down
9 changes: 6 additions & 3 deletions apps/bff/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import fastifyCookie from '@fastify/cookie';
import { AppModule } from './app.module.js';
import { corsOrigin, isProduction } from './env.js';
import { JsonLogger } from './logging/json-logger.js';
import { StageErrorFilter } from './filters/stage-error.filter.js';
import { VALIDATION_PIPE_OPTIONS } from './validation.js';

export async function createApp(adapter: FastifyAdapter): Promise<NestFastifyApplication> {
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, {
logger: ['error', 'warn', 'log'],
logger: isProduction() ? new JsonLogger() : ['error', 'warn', 'log'],
});

await app.register(fastifyCookie);
Expand All @@ -34,10 +37,10 @@ export async function createApp(adapter: FastifyAdapter): Promise<NestFastifyApp
done();
});

app.enableCors({ origin: process.env.WEB_ORIGIN ?? true, credentials: true });
app.enableCors({ origin: corsOrigin(), credentials: true });
// sitemap.xml/robots.txt are excluded so they can live at the site root instead of under /api.
app.setGlobalPrefix('api', { exclude: ['sitemap.xml', 'robots.txt'] });
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalPipes(new ValidationPipe(VALIDATION_PIPE_OPTIONS));
if (process.env.STAGE !== 'prod') {
app.useGlobalFilters(new StageErrorFilter());
}
Expand Down
7 changes: 6 additions & 1 deletion apps/bff/src/contributions/contributions.module.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { Module } from '@nestjs/common';
import { ContributionsController } from './contributions.controller.js';
import { ContributionsService } from './contributions.service.js';
import { PendingContributionStore } from './pending-contribution.store.js';
import { DynamoPendingContributionStore } from './dynamo-pending-contribution.store.js';
import { ThingsModule } from '../things/things.module.js';
import { SearchModule } from '../search/search.module.js';

// No `imports: [MycotaAuthModule]` needed — it's registered `global: true`.
@Module({
imports: [ThingsModule, SearchModule],
controllers: [ContributionsController],
providers: [ContributionsService],
providers: [
{ provide: PendingContributionStore, useClass: DynamoPendingContributionStore },
ContributionsService,
],
})
export class ContributionsModule {}
Loading
Loading