Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 37 additions & 0 deletions .github/workflows/check-schema.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Check schema sync

on:
push:
branches: ["main", "master"]
pull_request:
branches: ["main", "master"]

jobs:
check-schema:
name: Check schema is in sync
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Checkout canonical schema repo (private)
if: ${{ secrets.BOUNDLESS_NESTJS_TOKEN != '' }}
uses: actions/checkout@v4
with:
repository: boundlessfi/boundless-nestjs
path: boundless-nestjs
token: ${{ secrets.BOUNDLESS_NESTJS_TOKEN }}

- name: Note about private repo
if: ${{ secrets.BOUNDLESS_NESTJS_TOKEN == '' }}
run: |
echo "No secret BOUNDLESS_NESTJS_TOKEN provided; skipping checkout of private repo."
echo "If the canonical schema is in the private repo, add a repository secret named BOUNDLESS_NESTJS_TOKEN with a PAT that has access to boundlessfi/boundless-nestjs."
Comment on lines +17 to +29
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

secrets context is not permitted in step if conditions — both conditional steps will never evaluate correctly.

The GitHub Actions documentation explicitly states that "Secrets cannot be directly referenced in if: conditionals" and recommends "setting secrets as job-level environment variables, then referencing the environment variables to conditionally run steps in the job." The actionlint tool confirms this — lines 18 and 26 will produce "Unrecognized named-value: 'secrets'" errors at parse time, causing the checkout step to never run regardless of whether the token is set.

🔧 Proposed fix — expose the secret as a job-level env var
 jobs:
   check-schema:
     name: Check schema is in sync
     runs-on: ubuntu-latest
+    env:
+      NESTJS_TOKEN: ${{ secrets.BOUNDLESS_NESTJS_TOKEN }}
     steps:
       - name: Checkout repository
         uses: actions/checkout@v4

       - name: Checkout canonical schema repo (private)
-        if: ${{ secrets.BOUNDLESS_NESTJS_TOKEN != '' }}
+        if: ${{ env.NESTJS_TOKEN != '' }}
         uses: actions/checkout@v4
         with:
           repository: boundlessfi/boundless-nestjs
           path: boundless-nestjs
           token: ${{ secrets.BOUNDLESS_NESTJS_TOKEN }}

       - name: Note about private repo
-        if: ${{ secrets.BOUNDLESS_NESTJS_TOKEN == '' }}
+        if: ${{ env.NESTJS_TOKEN == '' }}
         run: |
🧰 Tools
🪛 actionlint (1.7.11)

[error] 18-18: context "secrets" is not allowed here. available contexts are "env", "github", "inputs", "job", "matrix", "needs", "runner", "steps", "strategy", "vars". see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability for more details

(expression)


[error] 26-26: context "secrets" is not allowed here. available contexts are "env", "github", "inputs", "job", "matrix", "needs", "runner", "steps", "strategy", "vars". see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability for more details

(expression)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/check-schema.yml around lines 17 - 29, The workflow
incorrectly uses secrets in step-level if conditions (steps "Checkout canonical
schema repo (private)" and "Note about private repo") which GitHub disallows;
change to expose BOUNDLESS_NESTJS_TOKEN as a job-level environment variable
(e.g., under the job's env) and then update the steps to use that environment
variable in their if conditions (e.g., if: env.BOUNDLESS_NESTJS_TOKEN != '' and
if: env.BOUNDLESS_NESTJS_TOKEN == '') so actionlint/GitHub will evaluate them
properly while still using the secret stored as BOUNDLESS_NESTJS_TOKEN at the
job level.


- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'

- name: Run schema check
run: node ./scripts/sync-schema.js --check
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,34 @@ This project is licensed under the MIT License - see the [LICENSE](./LICENSE) fi

---

## Schema sync (CI)

This repository validates that the local `lib/graphql/schema.graphql` is kept in sync with the canonical GraphQL schema that lives in the backend repository.

- The GitHub Actions workflow `.github/workflows/check-schema.yml` runs `node ./scripts/sync-schema.js --check` on push and pull requests.
- If the canonical schema is stored in the private backend repo `boundlessfi/boundless-nestjs`, the workflow can check out that repo when you provide a repository PAT in the `BOUNDLESS_NESTJS_TOKEN` secret.

How to enable CI checks for a private backend repo:

1. Create a GitHub Personal Access Token (PAT) with `repo` (read) scope for `boundlessfi/boundless-nestjs`.
2. In this repository, go to **Settings → Secrets → Actions** and add a new secret named `BOUNDLESS_NESTJS_TOKEN` with the PAT value.
3. The workflow will automatically checkout `boundlessfi/boundless-nestjs` into the `boundless-nestjs` path and the sync script will copy `src/schema.gql` from there.

If you do not want to provide cross-repo access, two alternatives are supported:

- Set the `CANONICAL_SCHEMA` environment variable (or repository secret) to a path or URL where the canonical `schema.gql` can be found, or
- Keep a copied `lib/graphql/schema.graphql` file in this repo and update it manually when the backend schema changes.

Local commands:

```bash
# copy the canonical schema into this repo (useful for local development)
npm run sync-schema

# CI-friendly check (fails if out-of-sync)
npm run check-schema
```

## Acknowledgments

Built with:
Expand Down
125 changes: 70 additions & 55 deletions app/api/bounties/[id]/claim/route.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,75 @@
import { NextResponse } from 'next/server';
import { BountyStore } from '@/lib/store';
import { addDays } from 'date-fns';
import { getCurrentUser } from '@/lib/server-auth';
import { NextResponse } from "next/server";
import { BountyStore } from "@/lib/store";
import { getCurrentUser } from "@/lib/server-auth";

export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: bountyId } = await params;

try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const body = await request.json();
const { contributorId } = body;

// If client sends contributorId, ensure it matches the authenticated user
if (contributorId && contributorId !== user.id) {
return NextResponse.json({ error: 'Contributor ID mismatch' }, { status: 403 });
}

const bounty = BountyStore.getBountyById(bountyId);
if (!bounty) {
return NextResponse.json({ error: 'Bounty not found' }, { status: 404 });
}

if (bounty.claimingModel !== 'single-claim') {
return NextResponse.json({ error: 'Invalid claiming model for this action' }, { status: 400 });
}

if (bounty.status !== 'open') {
return NextResponse.json({ error: 'Bounty is not available' }, { status: 409 });
}

const now = new Date();
const updates = {
status: 'claimed' as const,
claimedBy: user.id, // Use authenticated user ID
claimedAt: now.toISOString(),
claimExpiresAt: addDays(now, 7).toISOString(),
updatedAt: now.toISOString()
};

const updatedBounty = BountyStore.updateBounty(bountyId, updates);

if (!updatedBounty) {
return NextResponse.json({ success: false, error: 'Failed to update bounty' }, { status: 500 });
}

return NextResponse.json({ success: true, data: updatedBounty });

} catch (error) {
console.error('Error claiming bounty:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
const { id: bountyId } = await params;

try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const body = await request.json();
const { contributorId } = body;

// If client sends contributorId, ensure it matches the authenticated user
if (contributorId && contributorId !== user.id) {
return NextResponse.json(
{ error: "Contributor ID mismatch" },
{ status: 403 },
);
}

// Use the validated contributorId or default to the authenticated user
const finalContributorId = contributorId ?? user.id;

const bounty = BountyStore.getBountyById(bountyId);
if (!bounty) {
return NextResponse.json({ error: "Bounty not found" }, { status: 404 });
}

if (bounty.type !== "FIXED_PRICE") {
return NextResponse.json(
{ error: "Invalid bounty type for this action" },
{ status: 400 },
);
}

if (bounty.status !== "OPEN") {
return NextResponse.json(
{ error: "Bounty is not available" },
{ status: 409 },
);
}

const now = new Date();
const updates = {
status: "IN_PROGRESS" as const,
updatedAt: now.toISOString(),
claimedBy: finalContributorId,
claimedAt: now.toISOString(),
};

const updatedBounty = BountyStore.updateBounty(bountyId, updates);

if (!updatedBounty) {
return NextResponse.json(
{ success: false, error: "Failed to update bounty" },
{ status: 500 },
);
}

return NextResponse.json({ success: true, data: updatedBounty });
} catch (error) {
console.error("Error claiming bounty:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
112 changes: 62 additions & 50 deletions app/api/bounties/[id]/competition/join/route.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,69 @@
import { NextResponse } from 'next/server';
import { BountyStore } from '@/lib/store';
import { CompetitionParticipation } from '@/types/participation';
import { getCurrentUser } from '@/lib/server-auth';
import { NextResponse } from "next/server";
import { BountyStore } from "@/lib/store";
import { CompetitionParticipation } from "@/types/participation";
import { getCurrentUser } from "@/lib/server-auth";

const generateId = () => crypto.randomUUID();

export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: bountyId } = await params;

try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

const bounty = BountyStore.getBountyById(bountyId);
if (!bounty) {
return NextResponse.json({ error: 'Bounty not found' }, { status: 404 });
}

if (bounty.claimingModel !== 'competition') {
return NextResponse.json({ error: 'Invalid claiming model for this action' }, { status: 400 });
}

// Validate status is open
if (bounty.status !== 'open') {
return NextResponse.json({ error: 'Bounty is not open for registration' }, { status: 409 });
}

const existing = BountyStore.getCompetitionParticipationsByBounty(bountyId)
.find(p => p.contributorId === user.id);

if (existing) {
return NextResponse.json({ error: 'Already joined this competition' }, { status: 409 });
}

const participation: CompetitionParticipation = {
id: generateId(),
bountyId,
contributorId: user.id, // Use authenticated user ID
status: 'registered',
registeredAt: new Date().toISOString()
};

BountyStore.addCompetitionParticipation(participation);

return NextResponse.json({ success: true, data: participation });

} catch (error) {
console.error('Error joining competition:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
const { id: bountyId } = await params;

try {
const user = await getCurrentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const bounty = BountyStore.getBountyById(bountyId);
if (!bounty) {
return NextResponse.json({ error: "Bounty not found" }, { status: 404 });
}

if (bounty.type !== "COMPETITION") {
return NextResponse.json(
{ error: "Invalid bounty type for this action" },
{ status: 400 },
);
}

// Validate status is open
if (bounty.status !== "OPEN") {
return NextResponse.json(
{ error: "Bounty is not open for registration" },
{ status: 409 },
);
}

const existing = BountyStore.getCompetitionParticipationsByBounty(
bountyId,
).find((p) => p.contributorId === user.id);

if (existing) {
return NextResponse.json(
{ error: "Already joined this competition" },
{ status: 409 },
);
}

const participation: CompetitionParticipation = {
id: generateId(),
bountyId,
contributorId: user.id, // Use authenticated user ID
status: "registered",
registeredAt: new Date().toISOString(),
};

BountyStore.addCompetitionParticipation(participation);

return NextResponse.json({ success: true, data: participation });
} catch (error) {
console.error("Error joining competition:", error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 },
);
}
}
Loading