A property and space management demo application built with React and TypeScript.
It has a Node.js + TypeScript REST API in backend/.
This is a portfolio/demo project with fictional data. The public demo shares one dataset through the demo API, which anyone can change and which returns to the demo data whenever the API restarts. Don't enter real personal data.
frontend/ React + TypeScript + Vite single-page application
backend/ Node.js + TypeScript REST API (Express), see backend/README.md
.github/ CI, release and developer agent workflows, Dependabot and Copilot agent profiles
CHANGELOG.md Release notes for every version
docker-compose.yml Local development with Docker
AGENTS.md Development workflow and conventions for humans and AI agents
CLAUDE.md Imports AGENTS.md for Claude Code
Requirements: Node.js 24 LTS (see frontend/.nvmrc) and npm.
cd frontend
npm ci
npm run devThe development server runs at http://localhost:5173.
The API is a separate project in backend/. The demo runs without it, because by default the frontend stores its data in the browser; only the AI features (maintenance suggestions and Ask JalaSpace) and the api data provider (see Data layer) need it:
cd backend
npm ci
npm run devThe API runs at http://localhost:3000 (try http://localhost:3000/api/health). Its interactive documentation is at http://localhost:3000/docs. See backend/README.md.
To try the AI features locally, copy backend/.env.example to backend/.env and set GEMINI_API_KEY, and set VITE_API_URL=http://localhost:3000 in frontend/.env.local (Docker Compose already sets the frontend and CORS variables). Then restart both.
Requirements: Docker with Docker Compose.
docker compose upThe app runs at http://localhost:5173, and the API at http://localhost:3000.
-
With Docker the app keeps its data on the API (
VITE_DATA_PROVIDER=apiindocker-compose.yml): every browser sees the same data, and it returns to the demo data whenever the API restarts. -
The
frontend/andbackend/sources are mounted into their containers, so edits reload live. -
node_modulesstays inside the container and isn't written to your machine. -
The containers run as the unprivileged
nodeuser.
The ./dev script in the repository root shortens the common commands. Without a service name, a command acts on both frontend and backend; add one to limit it, e.g. ./dev logs backend.
| Task | ./dev |
Docker Compose |
|---|---|---|
| Start in the background | ./dev up |
docker compose up -d |
| Follow logs | ./dev logs |
docker compose logs -f |
| Show the containers | ./dev ps |
docker compose ps |
| Stop | ./dev down |
docker compose down |
| Restart with new containers | ./dev restart |
docker compose up -d --force-recreate |
Rebuild after package-lock.json changes |
./dev rebuild |
docker compose up -d --build -V |
| Open a shell in a container | ./dev shell backend |
docker compose exec backend sh |
| Run lint, typecheck and tests | ./dev check |
docker compose exec frontend npm run test, … |
-V recreates the container's node_modules volume, so newly installed dependencies are picked up.
./dev lint, typecheck, test and check use the running container, or a temporary one when the containers are stopped. ./dev help lists all commands. End-to-end tests run on the host (cd frontend && npm run test:e2e).
./dev deploy and ./dev cloud-env manage the backend on Google Cloud Run with gcloud instead of Docker (see Backend (API)).
Sign in with the demo account:
| Password | |
|---|---|
demo@jalaspace.app |
demo |
Click your name in the header to edit your profile (first name, last name and birthdate) under Settings. Settings also has the language choice and Reset demo data.
This sign-in is simulated and not secure. Credentials are checked in the browser and are public. The session is kept in
localStorage(jalaspace_session) and exists only in your browser.
The Dashboard (/) opens with the JalaSpace logo and the key figures: properties, spaces, occupancy and
open maintenance, each linking to its list. Below are the recent maintenance tasks, the available spaces
(flagged when reserved by an upcoming lease) and recent activity. All figures are calculated from the data.
The Dashboard has an Ask JalaSpace card: ask in English or Finnish, and it shows where to go in the app or which records match.
- "where can I find the API documentation?" links to the API docs, and "missä vaihdan kielen?" links to Settings › Language.
- "an available three-room apartment with a sauna" or "kolmio saunalla Helsingissä" lists the matching spaces. So do questions about properties, tenants, leases and maintenance, e.g. "overdue high-priority plumbing tasks" or "leases ending in the next 3 months".
How it works:
- The question goes to the API, which asks Google Gemini to turn it into a place in the app or a search filter. The AI never sees the data: the app searches its own data with the filter, so every result is a real record.
- The card shows how the question was understood as conditions, e.g. Rooms: 3 and Features: Sauna. Remove one to widen the search. Words it could not use, such as cheap, are listed as not used.
- Each result links to its page. Open in Spaces (or the other list pages) opens the list with the same filters when the list page supports them.
- The card tells users not to include personal information, because the question leaves the browser. It is shown only when
VITE_API_URLis set and the API has a Gemini key.
Screenshots: example questions · desktop · mobile, in Finnish.
A property's details page shows where the building is on an OpenStreetMap map. To set or change the location, edit the property:
- Search address is pre-filled from the address fields. Press Search (or Enter) and choose a match to move the pin there. Searches are limited to Finland, and the address is sent to OpenStreetMap.
- Drag the pin, or click the map, to fine-tune the location. The Latitude and Longitude fields follow the pin and can also be edited directly.
- Zoom the map to the level you want to see: the zoom level is saved with the location, and the details page opens the map at it.
- Clear location removes it; the location is optional.
The maps use Leaflet with OpenStreetMap's free map tiles and address search (Nominatim), so no API key or billing account is needed. To follow their usage policies, the search runs only when asked (never while typing), at most once per second, and repeated searches are answered from a cache.
Screenshots: details page · location in the form.
JalaSpace is available in English and Finnish. Switch the language from the selector in the header, on the sign-in page or in Settings. The page updates immediately.
- On the first visit, the language follows the browser: Finnish for
fi*, otherwise English. - The choice is saved in
localStorage(jalaspace_language) and kept when the demo data is reset. - Numbers, percentages and currency follow the language (
85%/85 %); dates ared.m.yyyyin both. - User content, such as property names and maintenance titles, isn't translated. The demo data is in English.
Translations live in frontend/src/i18n/locales/. English (en.ts) defines the keys; the Finnish dictionary is type-checked against it, so a missing translation fails the build. See the Internationalization section in AGENTS.md.
The app has a user handbook in English and Finnish: open Handbook in the sidebar, or select the ? button in the header to open the chapter for the page you're on.
/helplists the ten chapters, grouped like the sidebar: getting started, the Dashboard and Ask JalaSpace, each area of the app, settings, and walkthroughs of tasks that cross several pages (from an application to a lease, a maintenance task from report to completion, a new property with its spaces).- Each chapter (
/help/maintenance) links to its sections, into the app and to the previous and next chapter. Section links such as/help/maintenance#addopen at that section. - The handbook follows the language switcher and names buttons and fields exactly as the UI shows them.
The content lives in frontend/src/help/: one typed file per language, checked against the chapter structure. See the Handbook section in AGENTS.md.
Screenshots: desktop chapter · mobile, in Finnish.
Open Spaces (/units) to search by space or current tenant and filter by property, status,
number of rooms (1–4 or 5+) and features (a space must have every chosen feature).
Filters stay in the URL and survive a reload. Use Add space on a property's details page
to create a space with that property already selected, or select a space to edit it.
- Names must be unique within a property (ignoring case), with at most 50 characters.
- Floors are whole numbers from −10 to 200; areas are positive, at most 100 000 m², with up to two decimals. A decimal comma is accepted.
- Rooms are optional, a whole number from 1 to 50.
- Features are chosen from a fixed list: sauna, balcony, furnished, parking, accessible, loading dock and kitchen.
- An active lease locks the status to Occupied; otherwise choose Available or Maintenance.
- Deleting requires confirmation and is blocked while any lease or maintenance task refers to the space.
- A space with maintenance tasks cannot move to another property until those tasks are removed or reassigned.
- Saves recheck validation against current repository data. Changes persist in this browser.
Screenshots: desktop list · mobile form.
Open Maintenance (/maintenance) to see every task, newest first. Search titles and descriptions,
and filter by property, space, priority or status. Due by shows tasks due on or before a date
(type it or choose it from the calendar), and Overdue only shows open and in-progress tasks past
their due date. Filters stay in the URL, so they survive a reload and can be shared as links.
Changing the property clears the space filter.
- Select a task to see its details and to Start work, Mark as completed or Reopen task.
- Use Add task on the list or on a property's details page, where the property is already selected.
- A task needs a property and a title (at most 120 characters). The space is optional: leave it empty for the whole property or a common area; otherwise it must belong to the chosen property.
- The due date is optional. Clicking the field opens a calendar, and you can still type the date as
d.m.yyyy(weeks start on Monday; Arrow Down moves into the calendar, arrow keys, Page Up/Down and Escape work). Past dates are allowed, and open tasks past their due date are marked overdue. - Completing a task records the completion time. Editing a completed task keeps it; reopening clears it.
- Suggest with AI (below the description) sends the title and description to the API, which asks
Google Gemini for a title, a description, a category and a priority. A title alone is enough. The
description states the problem with only the facts you wrote, followed by a To check list of typical
things for a maintenance worker to check, so nothing made up is recorded as fact. The suggestion is shown as a card:
Apply suggestion fills in the fields, which you can still change before saving; nothing is saved
automatically. The button is
shown only when
VITE_API_URLis set and the API has a Gemini key. The form tells users not to include personal information, because the title and description leave the browser. The first suggestion can take up to a minute when the API has to start first. - Task statuses do not change space statuses. Deleting a task requires confirmation.
- Saves recheck the property and space against current repository data, and the form keeps your input if saving fails. Dashboard and property maintenance counts follow the changes.
Screenshots: desktop list · mobile task details.
A badge next to Applications in the sidebar shows how many applications are new (Submitted); it goes down as soon as one is handled, and over 100 it shows "+100".
Open Applications (/applications, under Leasing in the sidebar) to see rental applications from
people looking for a space, newest first, with the space applied for, the desired start date and the status.
Filter by status (or all open ones) and property, and search by name, contact person or email. Filters stay in the URL.
- An application's page shows the applicant's contact details, the space and property, and the message.
- Approve creates a tenant from the applicant (or links the existing tenant with the same email) and opens the lease form with the tenant, space and desired start filled in; the lease is created when you save it. After approving, the other open applications for the space can be rejected together.
- Start review, Reject and Mark as withdrawn record the decision; rejecting and withdrawing ask for confirmation and cannot be undone. Statuses: Submitted, In review, Approved, Rejected, Withdrawn.
- An open application whose space has been let, reserved or taken into maintenance meanwhile is flagged.
- A space with applications cannot be deleted. Deleting an application requires confirmation.
People looking for a space apply without signing in: Browse available spaces and apply below the
sign-in form opens /apply, which lists the spaces that can be applied for as cards (filter by city and type).
Each space has its own form with the address, key facts, features and a map. The manager can open the form
from the Applications page and from each available space on the Spaces list and property page.
The form reminds that this is a demo: do not enter real personal information.
Screenshots: desktop list · mobile application details · approving · approved · available spaces · application form · mobile form · sign-in page.
Open Tenants (/tenants) to see companies and people, sorted by name, with their contact details and current spaces.
Search by name, contact person or email, and filter by type. Filters stay in the URL.
- Add tenant asks for the type (company or person), name, email and optional phone and notes; companies can also have a contact person. Emails must be unique, ignoring case.
- A tenant's page shows their current and upcoming spaces, details and past leases.
- Assign to space opens a new lease with the tenant already selected (see Leases).
- Remove from space moves the tenant out: a running lease ends yesterday and the space becomes Available, and a lease that has not started yet is cancelled. To schedule a later move-out, use Edit lease and set an end date.
- A tenant with leases, including past ones, or with an approved application cannot be deleted, so the history is kept. The tenant's page links to their approved applications.
- An occupied space's page links to its tenant (view and edit), and a property's spaces table shows each space's current tenant.
Screenshots: desktop list · mobile tenant details.
Open Leases (/leases) to see every lease with its tenant, space, period, monthly rent and status.
The status follows the dates: Upcoming, Active or Ended, counting both the start and the end day.
Filter by status and property, or search by tenant or space name. Filters stay in the URL.
- New lease connects a tenant to a space: choose the tenant, property and space, a start date, an optional end date (empty means open-ended) and an optional monthly rent. The form shows whether the lease will be upcoming or active today.
- Edit lease changes the period and rent. A future end date schedules the move-out; the tenant and space of a lease stay fixed, so moving a tenant means ending one lease and creating another.
- Leases of the same space cannot overlap, the end date cannot be before the start date, and a lease that is active today cannot start on a space in maintenance.
- A space is Occupied while it has an active lease and Available otherwise. The app updates space statuses when a lease is saved and every time it starts, so leases that start or end over time are reflected in the spaces list, occupancy and dashboard.
- Tenant pages and occupied spaces link to their leases.
Screenshots: desktop list · mobile lease form.
Run these inside frontend/:
| Script | Description |
|---|---|
npm run dev |
Start the Vite development server |
npm run lint |
Lint with oxlint (warnings fail the check) |
npm run typecheck |
Type-check with the TypeScript compiler |
npm run test |
Run unit and component tests (Vitest) |
npm run test:e2e |
Run end-to-end tests in a browser (Playwright) |
npm run test:e2e:ui |
Run end-to-end tests in Playwright's UI mode |
npm run test:e2e:api |
Run the end-to-end tests of the api data provider against the real API |
npm run build |
Type-check and create a production build in dist/ |
npm run preview |
Serve the production build locally |
Playwright tests in frontend/e2e/ run the production build in Chromium, on a desktop and a mobile viewport. They cover sign-in and sign-out, navigation, direct links and the mobile navigation drawer.
Install the browser once, then run the tests:
cd frontend
npx playwright install chromium
npm run test:e2eThe tests build the app and serve it with vite preview on port 4173. After a run, npx playwright show-report opens the HTML report. They always use the localStorage data provider, whatever .env.local says.
Tests named *.api.spec.ts check the api data provider with the real API: the dashboard shows the API data, a new property is stored on the API and visible in another browser, a task is completed, a lease occupies its space, and Reset demo data restores the API data.
cd backend && npm ci && cd ../frontend
npm run test:e2e:apiplaywright.api.config.ts starts the API with its demo data on port 3100 and serves an api build on port 4174. The API data is shared, so these tests run one at a time and reset it before each test. The report opens with npx playwright show-report playwright-report-api.
frontend/src/
├── components/ Reusable presentation components (Sidebar, Header, PageHeader, ...)
├── config/ Static configuration: navigation, data provider selection
├── layouts/ Application shell layouts
├── pages/ Route-level page components
├── hooks/ Custom React hooks
├── i18n/ Translations (English, Finnish), language switching and locale formatting
├── help/ The user handbook: chapter structure and content in each language
├── features/ Feature modules (auth, ...)
├── repositories/ Repository interfaces and their localStorage and API implementations
├── services/ Business logic (auth, lease status, dashboard statistics, demo data)
├── types/ Domain types (Property, Space, Tenant, Lease, MaintenanceTask, ...)
├── utils/ Helpers such as date handling
├── data/seed/ Demo seed data
├── styles/ Design tokens and global styles
└── router.tsx Route definitions (React Router)
Pages never touch localStorage or fetch directly. They go through repository interfaces, so the storage can be swapped without changing the UI:
React UI → custom hook → Repository interface → LocalStorageRepository (VITE_DATA_PROVIDER=localStorage)
→ ApiRepository → REST API (VITE_DATA_PROVIDER=api)
-
Data provider:
VITE_DATA_PROVIDERselects the implementation. Seefrontend/.env.example; copy it to.env.localto override locally, and restart the dev server afterwards, because Vite reads it at start.Provider Data is kept Used by localStorage(default)In this browser only npm run dev, Vercel preview deployments, testsapiOn the JalaSpace API, shared by every browser; needs VITE_API_URLThe public demo on Vercel, Docker Compose -
apiprovider: the API assigns ids and timestamps and checks the business rules again. The browser does not seed data; the API starts with its own demo data. The signed-in session, profile, profile image, password and language stay in the browser in both modes. -
Storage keys:
jalaspace_properties,jalaspace_units,jalaspace_tenants,jalaspace_leases,jalaspace_maintenance,jalaspace_applications,jalaspace_sessionandjalaspace_seed_version. -
Seed data: on the first visit the app seeds a demo portfolio: 4 properties, 68 spaces, 31 tenants, 62 leases, 14 maintenance tasks and 7 applications. Dates are relative to today, so the demo always has current, upcoming and past activity. Later visits keep your changes.
-
Reset: Settings → Demo data → Reset demo data (after confirmation) clears all JalaSpace data except the signed-in session and the language, restores the seed and restores the default demo profile. With the
apiprovider it asks the API to restore its demo data (POST /api/demo/reset), which affects everyone who uses that API. -
Data conventions:
- Timestamps are ISO strings (
2026-09-22T10:30:00.000Z), and calendar dates are date-only strings (2026-09-22). - Rent is stored in euro cents.
- A lease's status (upcoming, active or ended) is derived from its dates, not stored.
- Timestamps are ISO strings (
GitHub Actions runs ci.yml on every pull request and on pushes to main.
Each check is a separate job:
| Job | Command |
|---|---|
lint |
npm run lint |
typecheck |
npm run typecheck |
test |
npm run test |
build |
npm run build |
e2e |
npm run test:e2e (report kept on failure) |
e2e-api |
npm run test:e2e:api: the api data provider against the real API (report kept on failure) |
security-audit |
npm audit --audit-level=high |
docker-build |
docker build of frontend/Dockerfile |
backend-lint, backend-typecheck, backend-test, backend-security-audit |
The same checks for backend/ |
backend-docker-build |
Builds both stages of backend/Dockerfile, starts the production image and calls /api/health |
On a pull request, a changes job first looks at the changed files and runs only the checks they need; the others are skipped, which counts as passed:
| Changed files | Checks that run |
|---|---|
frontend/ |
lint, typecheck, test, build, e2e, e2e-api |
backend/ |
backend-lint, backend-typecheck, backend-test, backend-docker-build, e2e-api |
frontend/Dockerfile, frontend/.dockerignore, frontend/package.json, frontend/package-lock.json |
docker-build |
.github/workflows/ci.yml |
every check |
| anything else, e.g. documentation | only the security audits |
security-audit and backend-security-audit always run, because new advisories appear without any change in the repository. Pushes to main, and the checks that deploy.yml runs before deploying, always run every check.
After the checks pass on main, deploy.yml deploys a changed backend to Cloud Run (see Deployment).
The Node.js version is read from frontend/.nvmrc and backend/.nvmrc, so CI and local development use the same version.
pr-conventions.yml runs the commit-types check on every pull request: the PR title must be a Conventional Commit, and branch commits must not use release types (see Versions and releases).
Dependencies are also monitored between pull requests:
dependency-check.ymlrunsnpm auditweekly for bothfrontend/andbackend/and writes annpm outdatedreport to the job summary. It can also be started manually.- Dependabot opens weekly update PRs for npm packages and GitHub Actions. Minor and patch updates are grouped; major updates arrive as separate PRs. These PRs go through the same CI and human review as any other change.
The frontend is deployed to Vercel at https://jalaspace-demo.jussialanen.com (https://jalaspace.vercel.app redirects there).
| Setting | Value |
|---|---|
| Root Directory | frontend |
| Framework Preset | Vite |
| Build Command | npm run build |
| Output Directory | dist |
| Node.js Version | 24.x |
| Production Branch | main |
| Environment | Production: VITE_DATA_PROVIDER=api; Preview: VITE_DATA_PROVIDER=localStorage; both: VITE_API_URL=https://jalaspace-backend-1097769575593.europe-north1.run.app |
- Every pull request gets a preview deployment for review. Previews keep their data in the reviewer's browser, so reviewing a pull request never changes the public data; AI suggestions still use the API.
- Production reads and saves its data through the API on Google Cloud Run (see below).
- Merging to
maindeploys to production, so only reviewed code reaches production. frontend/vercel.jsonservesindex.htmlfor all application routes, so direct links and page refreshes work with client-side routing. Static files are served before the rewrite applies.- The demo is kept out of search engines: every page has
<meta name="robots" content="noindex, nofollow">, andvercel.jsonsends anX-Robots-Tag: noindex, nofollowheader with every response.robots.txtdeliberately allows crawling, because crawlers must fetch a page to see its noindex.
The API runs on Google Cloud Run as the service jalaspace-backend in europe-north1 (Finland), always on:
https://jalaspace-backend-1097769575593.europe-north1.run.app
(health check: /api/health, API documentation: /docs).
| Setting | Value | Why |
|---|---|---|
| Image | The production stage of backend/Dockerfile: runtime dependencies only, node src/server.ts |
Cloud Build builds it from backend/ |
| Minimum instances | 1 | One instance always runs, so visitors never wait for a cold start |
| Maximum instances | 1 | The shared demo data is in the instance's memory; a second instance would show different data. Also caps the cost |
| CPU | 1, only during requests (the default) | An idle instance is billed at the much lower idle rate. Don't use --no-cpu-throttling: it bills the CPU all the time |
| Memory | 512 MiB | |
| Startup CPU boost | On | |
| Access | Public (--allow-unauthenticated) |
The browser calls the API directly |
| Environment | backend/cloud-run.env.yaml: SEED_DEMO_DATA, TRUST_PROXY, CORS_ORIGINS; GEMINI_API_KEY from Secret Manager (jalaspace-gemini-api-key); Cloud Run sets PORT |
Every deploy applies them |
-
Costs: roughly 8–10 US dollars a month, almost all of it the idle instance (see Cloud Run pricing). Cloud Build's free monthly minutes cover the builds, and the Artifact Registry cleanup policy keeps the three newest images. A budget alert (Billing → Budgets & alerts) emails when costs go over the amount; it doesn't stop the service. With
--min-instances 0the service fits in the free tier, but the first visit after a quiet period waits a few seconds. -
Deploys:
deploy.ymldeploys the backend on every push tomainthat changesbackend/(includingcloud-run.env.yaml), after the CI checks pass, and then calls/api/health. It can also be started from the Actions tab (Deploy backend → Run workflow). It signs in with Workload Identity Federation, so no Google key is stored in GitHub, and only this repository'smainbranch can use it. Without theGCP_*repository variables it is skipped. -
Manual deploys:
./dev deploydeploys the backend in your working copy, with the same variables and secrets as the workflow, to gcloud's active project and prints the project first. Extra arguments go togcloud run deploy, e.g../dev deploy --quiet. Deploy only reviewed code frommain: the service is production. -
Environment variables are in
backend/cloud-run.env.yaml: change them there in a pull request, and the deploy after merging sets exactly the variables in the file (a variable removed from it is removed from the service). The file is public, so it never contains secrets. Secrets live in Secret Manager; the deploy refers to them by name only (SECRETSindeploy.yml,CLOUD_RUN_SECRETSindev). -
./dev cloud-envinspects and changes the service directly, in gcloud's active project.setandunsetlast only until the next deploy, so they suit trying a value; each change starts a new instance, which restores the demo data:Command What it does ./dev cloud-env listShows the variables; secrets by name only, never their values ./dev cloud-env set KEY=VALUE …Adds or changes variables until the next deploy; values may contain commas, e.g. CORS_ORIGINS./dev cloud-env unset KEY …Removes variables until the next deploy ./dev cloud-env secret KEYAsks for the value without showing it, stores it in Secret Manager ( GEMINI_API_KEY→jalaspace-gemini-api-key), lets the service read it and gives it to the service asKEY. Run it again to replace the valueVariables belong to this service alone, so they need no prefix even though JalaOps runs in the same project. Secret names are shared by the whole project, so they start with
jalaspace-.A new secret must also be added to
SECRETSindeploy.ymlandCLOUD_RUN_SECRETSindev; the command reminds you.Other settings (instances, CPU, memory) stay as they were set by hand:
gcloud run services update jalaspace-backend --region europe-north1 --min-instances 0, for example. -
The API keeps its data in memory, so changes are lost whenever the instance restarts: on every deploy or settings change, and now and then when Google moves the instance. It then starts again with the demo data (
SEED_DEMO_DATA=true). It also restores the demo data every night at 03:00 UTC (DEMO_RESET_AT). A database comes later. -
The public frontend (Vercel Production) reads and saves all data through this API, so every visitor shares one dataset. Sign-in is simulated in the browser, so anyone can change the data or reset it for everyone; it is demo data only.
-
The API limits writes per visitor (60 per minute, 10 demo resets per hour) and the number of stored records, so no one can flood the shared data or fill the server's memory. See Limits. Cloud Run adds the visitor's address last in
X-Forwarded-For, soTRUST_PROXY=1reads it correctly. -
CORS_ORIGINSallows the production site (the custom domain and itsvercel.appaddress), this project's own preview URLs (jalaspace-…-juzapalagmailcoms-projects.vercel.app) andhttp://localhost:5173, so a localnpm run devcan use the deployed API. A plainhttps://jalaspace-*.vercel.appwould also match other people's Vercel projects.
One-time Google Cloud setup
gcloud uses the project ID (client-jussialanen), which can differ from the project name shown in the Console; another project has the same name. The project already has the Cloud Run, Cloud Build and Artifact Registry APIs, roles/run.builder for the Compute Engine default service account, the Workload Identity pool github and the deployer account github-deployer, which JalaOps set up. JalaSpace adds its own provider to the pool.
PROJECT_ID=client-jussialanen
REGION=europe-north1
REPO=jussipalanen/jalaspace
PROJECT_NUMBER=$(gcloud projects describe $PROJECT_ID --format='value(projectNumber)')
DEPLOYER=github-deployer@$PROJECT_ID.iam.gserviceaccount.com
# First deploy: creates the service with the settings above.
gcloud run deploy jalaspace-backend --source backend --project $PROJECT_ID --region $REGION \
--allow-unauthenticated --min-instances 1 --max-instances 1 --cpu 1 --memory 512Mi --cpu-boost \
--env-vars-file backend/cloud-run.env.yaml
# The Gemini key as a Secret Manager secret the service can read (asks for the key, doesn't show it).
./dev cloud-env secret GEMINI_API_KEY
# Let GitHub Actions in this repository's main branch deploy as github-deployer.
gcloud iam workload-identity-pools providers create-oidc jalaspace \
--project $PROJECT_ID --location=global --workload-identity-pool=github \
--issuer-uri=https://token.actions.githubusercontent.com \
--attribute-mapping=google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref \
--attribute-condition="assertion.repository == '$REPO' && assertion.ref == 'refs/heads/main'"
gcloud iam service-accounts add-iam-policy-binding $DEPLOYER --project $PROJECT_ID \
--role=roles/iam.workloadIdentityUser \
--member="principalSet://iam.googleapis.com/projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github/attribute.repository/$REPO"
# Tell the workflow where to deploy (repository variables: none of these is secret).
gh variable set GCP_PROJECT_ID --body $PROJECT_ID
gh variable set GCP_SERVICE_ACCOUNT --body $DEPLOYER
gh variable set GCP_WORKLOAD_IDENTITY_PROVIDER \
--body projects/$PROJECT_NUMBER/locations/global/workloadIdentityPools/github/providers/jalaspaceThen set VITE_API_URL on Vercel (Production and Preview) to the service URL and redeploy the frontend.
Before Cloud Run, the API ran on Render's free plan as a Node web service (Root Directory backend/, Build Command npm ci, Start Command npm start, Health Check Path /api/health, the same environment as above plus NODE_ENV=production and NODE_VERSION=24). The free plan sleeps after about 15 minutes without traffic, so the first request after that can take up to a minute; the frontend waits that long.
JalaSpace follows Semantic Versioning. Every version is tagged (v0.1.0, v0.1.1, v0.2.0 …), published as a GitHub Release, and listed in CHANGELOG.md.
Releases are prepared automatically by release-please from Conventional Commit messages:
feat:→ new minor version (0.1.0 → 0.2.0)fix:→ patch version (0.1.0 → 0.1.1)docs:,test:,ci:,chore:→ no release
Pull requests are merged with a merge commit titled with the PR title, so each PR title (e.g. feat(properties): …) becomes one changelog line. Commits on the branch use types that do not create a release (chore, test, docs …), which the commit-types check enforces.
After each merge to main, release-please keeps a release PR up to date with the next version and its CHANGELOG entry. Merging that PR creates the tag and the GitHub Release. See "Versioning and Releases" in AGENTS.md.
All changes go through a feature branch and a pull request that a human reviews and approves. See AGENTS.md for details.
Small, clearly defined issues can be implemented by an automated developer agent that runs in GitHub Actions (developer-agent.yml). It uses Claude Code through claude-code-action and acts as the jalaspace-dev-agent[bot] GitHub App.
- Read the issue first. Its text becomes the agent's task, so label only issues whose content you trust.
- Add the
ai-agentlabel. - The agent either opens a pull request that closes the issue, or comments on the issue explaining why the task needs a human and suggesting a plan.
- CI runs on the pull request, and you review and merge it as usual.
To run it again, remove the label and add it back. The run log and a report are on the workflow run in the Actions tab.
Its role and limits are in .github/developer-agent.md. The agent never merges: the ruleset on main requires a reviewed pull request, and the app has no permission to change workflows or repository settings. Only the listed git, gh and npm commands are allowed; it cannot install packages.
Setup (already done for this repository):
| Name | Kind | Value |
|---|---|---|
DEV_AGENT_CLIENT_ID |
Actions variable | Client ID of the jalaspace-dev-agent GitHub App (Contents, Issues and Pull requests: read and write) |
DEV_AGENT_PRIVATE_KEY |
Actions secret | The app's private key |
CLAUDE_CODE_OAUTH_TOKEN |
Actions secret | From claude setup-token; uses the maintainer's Claude subscription |
ai-agent |
Issue label | Starts the agent |
Two project-specific profiles live in .github/agents/:
| Agent | Use it for |
|---|---|
| JalaSpace Frontend Engineer | Implementing frontend issues using existing services, repositories, accessible English/Finnish UI, and behavior tests. |
| JalaSpace Quality Reviewer | Reviewing a PR or branch for reproducible defects, domain consistency, accessibility, and missing tests. Returns findings without editing source. |
Both profiles explicitly read AGENTS.md, which remains the source of shared project rules. They are selected manually, inherit the selected/default model, and do not configure additional MCP servers or secrets. The reviewer has shell access to run checks; its instruction to avoid source edits is a behavioral boundary, not a read-only sandbox.
After these files are merged into the default branch, open Copilot agents, select this repository, and choose a profile in the agent dropdown. Copilot cloud agent requires a paid Copilot plan and must be enabled for the repository. See GitHub's custom agent setup and configuration reference.
Example tasks:
- Frontend Engineer: “Implement the Maintenance issue using the existing Spaces patterns. Include English/Finnish text, persistence, and tests; prepare a PR.”
- Quality Reviewer: “Review PR # against its linked issue and main. Check domain relationships, validation, accessibility, and regression coverage. Report findings with file/line references; do not edit source.”
Use the engineer for implementation, then start a reviewer session on the resulting changes. These profiles do not automatically run on every PR or replace human approval. A human still reviews and merges changes; releases use the existing release workflow.