Backend
Layers 2–4 of six (the map) — you have a UI in the Shell; now give it a server.
The server side of the calendar — an authenticated API, its own database, and the rules for who may change what.
Three segments, each self-contained below. Work through them in order: they stack, because the database swaps the API's auth to DB-backed, and authorization seeds codes into that database.
| Segment | Enable it with | Then run |
|---|---|---|
| API + Auth | veni enable api | veni install && veni dev |
| Database | veni enable database | docker compose up -d calendar-db, then bun run db:migrate |
| Authorization | veni enable authz | bun run db:generate --custom, then bun run db:migrate |
| File storage (when the app keeps files) | veni enable storage | nothing locally; it works once the app is published and activated |
veni enable writes files and stops. It starts no container, creates no database, applies no migration. Every segment below therefore has a Run block naming what you run afterwards — skipping it is why an app that "enabled the database" still fails to connect.
authz, integration and tenancy go one step further: they write no files at all. They record the capability in veni.json and print the steps, because what they'd scaffold is your domain's, not the platform's. Everything they need — the guards, the RBAC tables, the seed file — already arrived with database.
API + Auth
The month view needs events to show — read and written somewhere that knows who is asking, and refuses strangers.
Enable.
veni enable api # Bun + Hono + Ignis, with the Shell token handshakeIt scaffolds api/ and points it at the Shell you're developing against. Authentication comes with it — identity works even before you add a database.
Configure. Nothing to hand-edit — enable api wrote api/.env (signing secret, port), and a veni register points the Shell URLs at the Shell you chose:
# api/.env — written by veni enable api, Shell URLs set by veni register
APP_ENV_PORT=3014
APP_ENV_JWT_SECRET=… # signs YOUR service token (generated)
APP_ENV_SHELL_JWKS_URL=http://localhost:3000/api/.well-known/jwks.json # whose tokens you trust (Dev: shell-api.dev.venizia.ai)
APP_ENV_SHELL_GRPC_URL=http://localhost:3000/api # the Shell's API, for the token exchange
# APP_ENV_CORS_ORIGINS=http://localhost:5173,http://localhost:4177 # left commented — see belowRe-registered against a different Shell later? Restart the API to apply. (The APP_ENV_DATABASE_URL line arrives with Database.)
APP_ENV_CORS_ORIGINS ships commented out — leave it that way in dev
Unset, the API allows APP_ENV_FRONTEND_URL plus localhost:5173 and localhost:4173, and only in development. Uncomment it and that fallback is gone: the value becomes the whole allow-list, so it must name both the Shell UI and your own UI — http://localhost:5173,http://localhost:4177. Setting it to the Shell alone is a common way to CORS-block your own app. And don't leave it blank-but-present: Ignis rejects any registered APP_ENV_* that's empty, so the API refuses to boot.
Run. veni dev starts every service the app has — now that api/ exists, running it from the app root brings the API up alongside the UI:
veni install && veni dev # API → :3014, UI → :4177Check it works. The API validates its env at boot, so a bad api/.env crash-loops with Environment validation failed: <VAR> naming the offender. Once it's up:
curl http://localhost:3014/api/health # → {"status":"ok"}
curl http://localhost:3000/api/.well-known/jwks.json # your Shell must return keys, not {"keys":[]}The UI fails silently instead: a wrong VITE_API_URL just sends calls nowhere. In the browser Network tab, the post-sign-in POST must hit http://localhost:3014/api/auth/exchange (right port, ending in /api). Other env failure modes are in Common snags.
How the UI resolves VITE_API_URL, if it's not the value you set
Order (utils/env.ts → getApiBase()): the runtime config window.__<serviceNameId>_env (from env-config.js, filled by envsubst in a container) first, then the build-time value from ui/.env. Placeholders like ${VITE_API_URL} are skipped, so in local dev the effective source is your ui/.env. window.__calendar_env in the console shows the runtime config. Changed ui/.env while previewing a build? Rebuild.
The handshake — authentication, generated
Authentication is entirely generated — you write no login, session, or JWT verify. The shape: the Shell broadcasts its RS256 token, your UI exchanges it at POST /api/auth/exchange, your API verifies it against the Shell's JWKS (public keys, no shared secret) and mints its own HS256 service token, and api-client.ts attaches that to every call, silently re-exchanging once on a 401. The token you then check is yours, not the Shell's: user.userId is your local users.id, resolved from your DB. The exchange and every claim, in full → · when it breaks, Common snags.
Your first endpoint
List events. A service holds the logic, a controller exposes it, you export both from their barrel, and you wire them in application.ts. authenticate: { strategies: ['jwt'] } verifies your token — it's opt-in, so a route without it is public; requireAuth(c) (from utils/auth-guards) hands you the user.
Prefer to start from a working file rather than a fragment? The scaffold ships src/controllers/domain.controller.ts.example — a full CRUD controller — and src/schemas/domain.schema.ts for the request/response shapes. Copy them and rename.
// src/services/events.service.ts
import { BaseService } from '@venizia/ignis'
export class EventsService extends BaseService {
constructor() { super({ scope: EventsService.name }) }
list() {
return [] as Array<{ id: string; title: string }> // in-memory for now; the DB comes with `veni enable database`
}
}// src/controllers/events.controller.ts
import { controller, get, BaseController, inject, HTTP } from '@venizia/ignis'
import type { Context } from 'hono'
import { EventsService } from '../services/events.service'
import { requireAuth } from '../utils/auth-guards'
@controller({ path: '/events' }) // served under /api → GET /api/events
export class EventsController extends BaseController {
constructor(
@inject({ key: 'services.EventsService' }) private events: EventsService,
) { super({ scope: EventsController.name, path: '/events' }) }
override binding(): void {} // required — BaseController declares it abstract
@get({
configs: {
path: '/',
authenticate: { strategies: ['jwt'] },
responses: { // required — the decorator's type demands it
[HTTP.ResultCodes.RS_2.Ok]: { description: 'Events' },
},
},
})
async list(c: Context) {
requireAuth(c) // 401 unless a valid token
return c.json({ data: this.events.list() })
}
}Both files reach application.ts through their barrel, which is what the // veni:*-barrel anchors are for — export there first, then bind:
// src/services/index.ts → at // veni:services-barrel
export * from './events.service'
// src/controllers/index.ts → at // veni:controllers-barrel
export * from './events.controller'// src/application.ts — extend the existing imports, then one line at each anchor
import { AuthController, EventsController } from './controllers'
import { /* …existing… */ EventsService } from './services'
bindServices() { /* …existing… */ this.service(EventsService) } // at // veni:services
bindControllers() { /* …existing… */ this.controller(EventsController) } // at // veni:controllersTwo lines the compiler insists on, and neither error names the real cause
override binding(): void {} — BaseController declares it abstract, so leaving it out fails with TS2515: Non-abstract class … does not implement inherited abstract member binding. And responses is required in every configs, even when you never document a schema: omit it and you get TS2741: Property 'responses' is missing, pointing at a zod-to-openapi type rather than at your route. There is no @service() decorator to match — services just extends BaseService.
Want a full, real satellite to read?
VENIZIA-AI/hrm is a complete production app on this exact stack — controllers, services, repositories, auth, migrations. When a fragment here leaves you unsure how a whole file fits together, open the equivalent in HRM.
Done when —
curl localhost:3014/api/healthreturns{"status":"ok"}, andGET /api/eventsreturns data for a signed-in user but401without a token.
Database
An event booked today is still there tomorrow — surviving a restart, and stored in Calendar's own database rather than a table borrowed from another system.
Enable.
veni enable database # Drizzle + Postgres — swaps auth from SSO-only to DB-backedIt turns on the DB layer, restores local user CRUD, and uncomments the DB line in api/.env. It writes files only — the container and the tables are the Run step below.
Configure. Nothing to edit. The line enable database uncommented already points at this app's own Postgres:
# api/.env — written for you by `veni enable database`
APP_ENV_DATABASE_URL=postgresql://postgres:password@localhost:15436/calendar_dbPorts run one per app, and the scaffolder assigns and publishes them: calendar takes :15436 — the next after Drive's :15435 (the per-app scheme is in Services) — and docker-compose.yml publishes that same port, so the host-run API reaches it.
Run. Two steps, and the API cannot start without both. docker-compose.yml already carries calendar-db, Calendar's own Postgres (one container per app, the SCS split, separate from the Shell's) — it just isn't running yet:
cd calendar
docker compose up -d calendar-db # first start also creates the calendar_db database
cd api && bun run db:migrate # → [migrate] ✅ All migrations applied successfullyUsing a Postgres you already run instead? Point APP_ENV_DATABASE_URL at it, create the database yourself, then run the migrate step.
Use db:migrate, not db:migrate:kit, in CI/prod
db:migrate runs the programmatic migrator (src/scripts/migrate.ts) and prints the full Postgres error — code, detail, hint, constraint. Plain drizzle-kit (db:migrate:kit) can swallow a SQL error and still exit 0, leaving a half-applied schema: its spinner clears the line before the message reaches stderr. db:migrate:verbose is the same migrator run from dist/ — that's the one the Kubernetes migrate job uses.
Check it works. The bundled migrations create the identity tables — users, roles, user_roles, permissions — and seed the two default roles:
docker compose ps # calendar-db reads "healthy"
cd api && bun run db:studio # Drizzle Studio: the four tables above, roles holding org-admin + end-userYour first table
Define events in src/models/schema.ts:
// src/models/schema.ts — the drizzle imports and `auditable` are already at the top of this file
import { pgTable, uuid, varchar, timestamp } from 'drizzle-orm/pg-core'
export const events = pgTable('events', {
id: uuid('id').primaryKey().defaultRandom(),
title: varchar('title', { length: 255 }).notNull(),
startsAt: timestamp('starts_at').notNull(),
endsAt: timestamp('ends_at').notNull(),
...auditable, // created_by / updated_by → users.id
createdAt: timestamp('created_at').notNull().defaultNow(),
})Then generate and apply a migration of your own:
cd api # the db scripts live in api/package.json
bun run db:generate # diff the schema → a new migration file
bun run db:migrate # apply it (local dev)The repository
A table is reached through a repository — the data-access layer. Services hold the logic and never touch Drizzle directly; the repository owns the queries. Two small files, a barrel export, one binding, and an injection.
First declare the entity that pairs the class with the table, in src/models/entities.ts:
import { BaseEntity, model } from '@venizia/ignis'
import { events } from './schema'
@model({ type: 'entity' })
export class EventEntity extends BaseEntity<typeof events> {
static override schema = events
}Then the repository itself, in src/repositories/events.repository.ts. Extend AuditedRepository (from repositories/base.repository, generated for you) rather than Ignis's DefaultCRUDRepository — that's what makes auditable fill itself in:
import { repository } from '@venizia/ignis'
import { AuditedRepository } from './base.repository'
import { EventEntity } from '../models/entities'
import { events } from '../models/schema'
import { DbDataSource } from '../datasources/db.datasource'
@repository({ model: EventEntity, dataSource: DbDataSource })
export class EventsRepository extends AuditedRepository<typeof events> {
// Inherited: create, createAll, updateById, updateAll, deleteById, find…
// Add your own queries here — `this.connector` is the Drizzle handle.
}Export it from the barrel, then bind it in src/application.ts at the // veni:repositories anchor:
// src/repositories/index.ts
export * from './events.repository'// src/application.ts
import { EventsRepository } from './repositories'
bindRepositories() { /* …existing… */ this.repository(EventsRepository) } // at // veni:repositoriesNow inject it into the service — the same @inject idiom as the controller, with a repositories. key — and EventsService.list queries through it instead of returning an array literal:
// src/services/events.service.ts — the in-memory version, grown up
import { BaseService, inject } from '@venizia/ignis'
import { EventsRepository } from '../repositories'
export class EventsService extends BaseService {
constructor(
@inject({ key: 'repositories.EventsRepository' }) private repo: EventsRepository,
) { super({ scope: EventsService.name }) }
list() { return this.repo.find({ filter: {} }) }
get(id: string) { return this.repo.findById({ id }) } // → the row, or null
create(data: { title: string; startsAt: Date; endsAt: Date }) {
return this.repo.create({ data }) // created_by fills itself
}
}Every method takes an options object — findById({ id }), not findById(id) — and find() returns a list, so reach for findById when you want one row.
Extend AuditedRepository, not DefaultCRUDRepository
It's the only thing that fills created_by / updated_by. Extend the plain base and writes still succeed — the columns just stay NULL, silently. How the audit rail works →
Done when —
bun run db:migrateapplied cleanly, and an event you create survives an API restart — a real row in theeventstable with a realcreated_by.
Authorization
Anyone signed in can see the calendar. Only the person who created an event — or an admin — may change it.
Enable.
veni enable authz # records the capability — writes no filesThe guards and the RBAC tables already arrived with the database, so there is nothing left to scaffold: this segment is your own codes, start to finish — name them, seed them, call a guard in each handler. Authorization is local to your app — the platform's job stops at handing you three inputs (who the user is, what roles they hold, what permissions they have). Deciding what those allow is yours, and so is the machinery you decide it with.
Configure. veni enable database already seeded the two roles and an example permission set named after your app — calendars:read / calendars:write / calendars:delete — in api/drizzle/0001_seed.sql. You ran db:migrate in the last segment, so that file is already applied: put your own codes in a migration of your own instead.
cd api
bun run db:generate --custom --name=seed_events_permissions
# → drizzle/0002_seed_events_permissions.sql, empty, and journalledFill it in — for the calendar, events:
-- api/drizzle/0002_seed_events_permissions.sql
-- Everyone (via the default 'end-user' role): read the calendar.
INSERT INTO permissions (code, role_id)
SELECT t.code, r.id FROM (VALUES ('events:read')) AS t(code)
JOIN roles r ON r.name = 'end-user'
ON CONFLICT (code) DO NOTHING;
-- Org admins only: create, edit, delete.
INSERT INTO permissions (code, role_id)
SELECT t.code, r.id FROM (VALUES ('events:write'), ('events:delete')) AS t(code)
JOIN roles r ON r.name = 'org-admin'
ON CONFLICT (code) DO NOTHING;permissions.code is globally unique (one code, one role), so put each code on the least privileged role that should hold it: codes on end-user mean everyone, codes on org-admin stack on top.
Editing an applied migration is a no-op, and it fails silently
The migrator decides what to run by timestamp, not by content: it compares each migration's when in drizzle/meta/_journal.json against the newest row in drizzle.__drizzle_migrations, and skips anything not newer. Editing 0001_seed.sql after it has been applied therefore changes nothing at all — no error, no warning, and db:migrate still prints All migrations applied successfully. Your codes never reach the table, and every permission check 403s for everyone including admins. Always add a new migration. (Renaming codes only ever works before the first db:migrate.)
Run. The inserts are ON CONFLICT DO NOTHING, so applying them is safe even if some codes already exist:
cd api && bun run db:migrate # → [migrate] ✅ All migrations applied successfullyCheck it works. A code that never reached the table denies everyone — that is the failure mode to recognise, and a 403 for every user including admins is its signature. So check the rows landed before you blame the guard, then sign in as the event's creator and edit it, and again as anyone else:
bun run db:studio # permissions holds events:read on end-user, events:write/delete on org-adminCodes are resolved into the token at exchange time, so sign out and back in after seeding new ones — an hour-old token still carries the old set.
Getting the inputs
All from utils/auth-guards, inside a handler whose route sets authenticate: { strategies: ['jwt'] }:
| Input | How you get it |
|---|---|
| User | requireAuth(c) → user.userId (the local users.id — use this for ownership and FKs), plus organizationId, email, name, shellSub. getAuthUser(c) is the non-throwing form for optional-auth routes. |
| Roles | getRoles(c) → ['end-user'] — or assert one with requireRole(c, 'manager') / requireAdmin(c). |
| Permissions | getPermissions(c) → ['events:read'] — or assert one with requirePermission(c, 'events:write'). |
Implementing the rule
For most apps the guards are the rule — a code check plus an ownership check, as the first statements in the handler. The guards live in the controller, where the request context is; the row still comes through the service, which owns the repository:
// src/controllers/events.controller.ts
import { requirePermission, requireOwnerOrAdmin } from '../utils/auth-guards'
import { validateParams } from '../utils/validation'
async update(c: Context) {
requirePermission(c, 'events:write') // 403 without the code
const { id } = validateParams(c, TIdParamSchema)
// Holding the permission is not enough — only the creator (or an admin) may
// edit this particular row. Load it first.
const existing = await this.events.get(id) // the injected service
requireOwnerOrAdmin(c, existing?.createdBy) // 403 unless owner or admin
// … apply the update through the service
}Note the ?. — a row that doesn't exist has no owner, so a missing id lands on the same 403 rather than leaking that it's missing. That reads the token, which was resolved from your database at exchange time.
When you outgrow codes
The three inputs are just data — feed them into whatever you like. Nothing about the platform requires the guards. If the calendar's rules outgrow flat codes (hierarchies, wildcards, per-resource policy), add a policy engine such as Casbin as your app's own dependency and enforce against your own tables. It stays an SCS decision: your repo, your dependency, your database.
Whichever you pick, the identity half (user.userId, user.organizationId) still comes from the token exactly as above — only the grants move.
Here's the whole thing with Casbin, the way the platform's apps do it. Name your codes once, in 'resource:action' form, and import them everywhere instead of loose strings:
// api/src/authz/permissions.ts — one source of truth
export const Permissions = {
EVENTS_READ: 'events:read',
EVENTS_WRITE: 'events:write',
EVENTS_DELETE: 'events:delete',
} as constThen guard a handler with an @authorize decorator — the same decorator idiom as @get, sitting directly under it:
@get({ configs: { path: '/:id', authenticate: { strategies: ['jwt'] },
responses: { [HTTP.ResultCodes.RS_2.Ok]: { description: 'Event' } } } })
@authorize({ permissions: [Permissions.EVENTS_WRITE] }) // 403 unless the caller holds it, in their org
async update(c: Context) {
// … the body runs only when Casbin allows it
}It enforces against the token's userId + organizationId (never a client-supplied id), and an org-admin passes everything.
The full shape — the model, the per-org cached policy adapter, the enforcer, and the @authorize decorator — is a reference page of its own: Authorization engine (Casbin) →.
Token claims are a snapshot — the real reason to reach for an engine
roles and permissions are resolved once, at token exchange, and the token lives an hour. Revoke a role and the old token still carries it until it expires. For coarse rules that's fine. When a revoke must bite immediately, don't check claims — check live state, where your database is the single source of truth. Identity is stable and safe to trust either way.
Done when — the event's creator (or an admin) can edit it, and anyone else gets a
403.
File storage
An event can carry an attachment (an agenda PDF, a photo), stored for the organization that booked it, and opened by the people who can see the event.
Enable.
veni enable storage # StorageClientService → the platform storage app, per organizationIt needs only api (not database). It writes:
api/src/services/storage-client.service.tsand its test;- the wiring in
application.tsand the services barrel; ErrorCode.STORAGE_UNAVAILABLE;APP_ENV_SHELL_API_URL,APP_ENV_STORAGE_API_URLandAPP_ENV_SHELL_WORKLOAD_TOKEN_PATHinenv.config.ts;- those values in the overlay. They point at the Shell's and storage's internal listeners (
…svc.cluster.local:3100/api).
It also mounts the API pod's ServiceAccount token (audience veni-shell) in deploy/k8s/base/api/deployment.yaml. That token is how the app proves which app it is.
Configure. Nothing. You create no secret, bucket or Vault entry. Once the app is published, an organization admin activating it (Kích hoạt) is all an organization needs. The installation-token flow behind it is on Store files on the storage app.
Use it. Inject the service and pass the organization of the signed-in user, never one from the request:
const { organizationId } = requireAuth(c);
const { id } = await this.storage.upload(organizationId, { contentType: file.type, bytes });
// keep `id` (and the file name) on your own row: storage does not record the nameTwo rules the client cannot enforce for you:
- A browser never gets a storage link.
getLink()andcreateUpload()'sputUrlare signed for the object store's in-cluster address. Uploads come to your API as bytes. For downloads, write a route that streamsfetchObject(). If the client is an<img>or a plain link, which cannot send a bearer token, hand out a short-lived HMAC-signed URL to that route (hrm'sfile-link.ts+files.controller.ts). - The preview has no storage. Preview tokens carry no organization, so every call answers
503 STORAGE_UNAVAILABLE, and so does a local run with the variables unset. Build the screen to say so. A403means the app is not active in that organization.
Done when: on the published app, an attachment uploaded in one organization opens there through your API, and is
404from another organization.
Email
When a booking is confirmed, the customer gets an email from the organization that runs the booking, and the screen shows whether it went out.
Enable.
veni enable email # EmailClientService → POST /apps/email on the Shell, per organizationIt needs only api. It writes:
api/src/services/email-client.service.tsand its test;- the wiring in
application.tsand the services barrel; ErrorCode.EMAIL_UNAVAILABLE,EMAIL_REFUSED,EMAIL_NOT_CONFIGURED,EMAIL_RATE_LIMITED,EMAIL_SEND_ANY_REQUIRED;- the Shell identity it shares with storage:
APP_ENV_SHELL_API_URLandAPP_ENV_SHELL_WORKLOAD_TOKEN_PATHinenv.config.tsand the overlay, and the ServiceAccount-token volume in the API deployment. If storage is already on, these are already there and are not added twice.
Configure. Nothing in the app. The Shell sends through the organization's own SMTP (an org admin sets it in Settings → Services → SMTP), or through the platform's when the organization has none. Through the organization's SMTP nothing is limited; through the platform's, each organization has an hourly budget and the platform a daily one.
Use it. Send after the write commits, to an address from your own data, with a key derived from the row:
const { organizationId } = requireAuth(c);
const { messages, simulated } = await this.email.send(organizationId, {
to: booking.customerEmail,
subject: `Booking ${booking.code} confirmed`,
text: `Hi ${booking.customerName},\n\nSee you on ${booking.date}.`,
replyTo: 'desk@acme.com',
idempotencyKey: `booking-${booking.id}-confirmed`,
});
// keep messages[0].messageId on the row; status() answers queued · sent · failedRules the client enforces, and the ones it cannot:
- Plain text only. The Shell escapes it, links
https://URLs, puts the organization's name on top and a footer naming your app. You never choose the From; setreplyTo. - An address a user typed goes through
sendFreeForm(c, …): only an org admin or a role holdingemail:send-anymay send it. Never pass a typed address tosend(). - The key makes retries safe. The same
idempotencyKeysends nothing while the mail is queued or sent, and sends it again once it failed. A random key defeats that. - The preview simulates. It has no organization, so
sendanswerssimulated: trueand nothing leaves; say so on screen. Deployed:409 EMAIL_NOT_CONFIGUREDmeans the organization has no SMTP,429means the platform budget is used up for now,403means the app is not active in that organization. A failed send must not undo the write it reports on.
Done when: on the published app, confirming a booking in an organization with SMTP mails the customer once (confirming it again does not mail twice), and the booking shows
sent.
Common snags
| Symptom | Fix |
|---|---|
TS2515: … does not implement inherited abstract member binding | The controller is missing override binding(): void {}. BaseController declares it abstract. |
TS2741: Property 'responses' is missing on a @get / @post | Every route's configs must carry responses, even a one-line { [HTTP.ResultCodes.RS_2.Ok]: { description: '…' } }. |
API crash-loops with Environment validation failed: <VAR> | api/.env is missing that key. enable api writes the file; a veni register fills the Shell URLs. |
ECONNREFUSED … 15436 after veni enable database | The container was never started — enable only writes files. Run docker compose up -d calendar-db. |
Queries fail with relation "users" does not exist | Postgres is up but empty. Run cd api && bun run db:migrate. |
403 for every user, including admins | The permission code never reached the permissions table. Add it in a new migration (bun run db:generate --custom), then bun run db:migrate — editing an already-applied 0001_seed.sql does nothing. |
db:migrate says All migrations applied but your SQL never ran | You edited an applied migration. The migrator compares timestamps, not content. Generate a new one. |
403 right after seeding a new code | The token is a snapshot from exchange time. Sign out and back in. |
| Browser reports a CORS error calling your own API | APP_ENV_CORS_ORIGINS is uncommented but names only the Shell. It replaces the whole allow-list — add http://localhost:4177 too, or re-comment the line. |
Writes succeed but created_by stays NULL | The repository extends DefaultCRUDRepository. Extend AuditedRepository instead. |
Storage calls answer 503 STORAGE_UNAVAILABLE | Expected in the preview and locally with the variables unset. On a published app, the cluster must allow the app's egress to shell-api:3100 and storage-api:3100. |
Storage calls answer 403 | The app is not activated in that organization (or a platform admin refused it for storage). Activate it. |
Shell returns {"keys":[]} from JWKS | The Shell is signing HS256 — set APP_ENV_JWT_PRIVATE_KEY on it and restart its API. |
More, across the whole build: Common snags.
Next: invite people from another system, and keep tenants apart. Cross-system →
Stuck? See Common snags, or back to Build a satellite.