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
18 changes: 11 additions & 7 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ HEALTHCHECK_SECRET=
REDIS_URL=redis://localhost:6395
JOBS_INPROCESS=
JOBS_HEALTH_PORT=
# dev:stack uses separate loopback health ports for its two background processes.
SWI_DEV_WORKER_PORT=3460
SWI_DEV_BEAT_PORT=3461
QUEUE_OUTBOX_CONCURRENCY=
QUEUE_SCHEDULED_CONCURRENCY=

Expand All @@ -53,16 +56,17 @@ S3_FORCE_PATH_STYLE=1
UPLOAD_MAX_BYTES=

# --- Email (bootstrap fallback; /admin settings override it) ---
# Without SMTP in development, emails are written to .data/mail/.
EMAIL_FROM=
SMTP_HOST=
SMTP_PORT=
SMTP_SECURE=
# Local Mailpit captures mail and never delivers it externally. Replace these
# local defaults with your authenticated TLS SMTP relay in production.
EMAIL_FROM="Space Works Inventory <inventory@localhost.test>"
SMTP_HOST=127.0.0.1
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_SERVICE=
SMTP_USER=
SMTP_PASSWORD=
# Local Mailpit only (docker compose): SMTP_HOST=localhost SMTP_PORT=1025.
SMTP_ALLOW_UNAUTHENTICATED=
# Local Mailpit only; do not enable for a public SMTP relay.
SMTP_ALLOW_UNAUTHENTICATED=1
OUTBOX_RETENTION_DAYS=

# --- Outbound webhooks ---
Expand Down
48 changes: 45 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ jobs:
env:
TEST_DATABASE_URL: postgresql://swi:swi_ci@localhost:5432/swi_ci_test
TEST_QUEUE_REDIS_URL: redis://127.0.0.1:6379/0
# Exercise the same private S3 backend as production, not disk fallback.
TEST_S3_BUCKET: swi-docsfiles
S3_ENDPOINT: http://127.0.0.1:9010
S3_REGION: us-east-1
S3_ACCESS_KEY_ID: swidev
S3_SECRET_ACCESS_KEY: swi_dev_storage_pw
S3_FORCE_PATH_STYLE: "1"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand All @@ -76,9 +83,9 @@ jobs:
run: |
docker run -d --name silo -p 127.0.0.1:9010:9000 \
-e MINIO_ROOT_USER=swidev -e MINIO_ROOT_PASSWORD=swi_dev_storage_pw \
pgsty/silo:RELEASE.2026-09-16T00-00-00Z server /data
pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7 server /data
for i in $(seq 1 30); do curl -fs http://127.0.0.1:9010/minio/health/live && break; sleep 1; done
docker run --rm --network host --entrypoint /bin/sh pgsty/mc:RELEASE.2026-09-16T00-00-00Z -c \
docker run --rm --network host --entrypoint /bin/sh pgsty/mc:RELEASE.2026-10-26T00-00-00Z@sha256:ce62c9053c69ce12a527a322345b5ad9f44b5f8a7b8189f82e516f2e3f30f1d8 -c \
'mc alias set l http://127.0.0.1:9010 swidev swi_dev_storage_pw && mc mb --ignore-existing l/swi-docsfiles'
# Applies committed migrations; also proves they run on an empty database.
- run: npm run db:test:prepare
Expand All @@ -102,6 +109,33 @@ jobs:
name: Production hosting smoke
runs-on: ubuntu-latest
timeout-minutes: 45
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: swi
POSTGRES_PASSWORD: swi_ci
POSTGRES_DB: swi_ci_test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U swi -d swi_ci_test"
--health-interval 5s --health-timeout 5s --health-retries 10
redis:
image: redis:7-alpine
ports: ["6379:6379"]
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgresql://swi:swi_ci@localhost:5432/swi_ci_test
DIRECT_URL: postgresql://swi:swi_ci@localhost:5432/swi_ci_test
REDIS_URL: redis://127.0.0.1:6379/0
S3_ENDPOINT: http://127.0.0.1:9010
S3_BUCKET: swi-browser
S3_REGION: us-east-1
S3_ACCESS_KEY_ID: swidev
S3_SECRET_ACCESS_KEY: swi_dev_storage_pw
S3_FORCE_PATH_STYLE: "1"
strategy:
fail-fast: false
matrix:
Expand All @@ -112,4 +146,12 @@ jobs:
with: {node-version: 22, cache: npm}
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e:${{ matrix.deployment }}
- name: Start Silo storage
run: |
docker run -d --name silo -p 127.0.0.1:9010:9000 \
-e MINIO_ROOT_USER=swidev -e MINIO_ROOT_PASSWORD=swi_dev_storage_pw \
pgsty/silo:RELEASE.2026-10-26T00-00-00Z@sha256:7bb2aff6b89efe1cb1303c10fc21d579447434ed06649ac9ec05a5e2dbf30ad7 server /data
for i in $(seq 1 30); do curl -fs http://127.0.0.1:9010/minio/health/live && break; sleep 1; done
docker run --rm --network host --entrypoint /bin/sh pgsty/mc:RELEASE.2026-10-26T00-00-00Z@sha256:ce62c9053c69ce12a527a322345b5ad9f44b5f8a7b8189f82e516f2e3f30f1d8 -c \
'mc alias set l http://127.0.0.1:9010 swidev swi_dev_storage_pw && mc mb --ignore-existing l/swi-browser'
- run: npm run test:e2e:${{ matrix.deployment }} -- --external-services --demo
23 changes: 23 additions & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,34 @@ jobs:
- run: npm run deployment:check
application:
uses: ./.github/workflows/ci.yml
cloudflare-cron:
name: Independent cron dependency and bundle check
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: {node-version: 22}
- name: Audit and dry-run the isolated cron package without credentials
shell: bash
run: |
set -euo pipefail
work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT
mkdir -p "$work/source" "$work/home" "$work/cache"
git archive HEAD deploy/cloudflare-cron | tar -xf - -C "$work/source"
cd "$work/source/deploy/cloudflare-cron"
rm -f -- .env .env.* .dev.vars .dev.vars.*
clean_env=(env -i "PATH=$PATH" "HOME=$work/home" "XDG_CONFIG_HOME=$work/home/config" "XDG_CACHE_HOME=$work/cache" CI=true WRANGLER_SEND_METRICS=false)
"${clean_env[@]}" npm ci --ignore-scripts --no-fund
"${clean_env[@]}" npm audit --audit-level=high --ignore-scripts
"${clean_env[@]}" npm run check
container:
name: Two-image installer smoke
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v4
- run: sudo apt-get update && sudo apt-get install -y age
- run: bash scripts/test-vps-install.sh
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@ build is tagged `v<version>-main.<run>.<commit>`; see

## [Unreleased]

## [0.6.0] - 2026-10-29

### Fixed

- Preserve public asset access for the nonroot backend when installation files use private directory and file modes.
- Preserve nonroot access to directly copied operator scripts and verify private script/directory modes in the image fixture.
- Pass explicit nonsecret installer fixture coordinates through environment-clearing sudo implementations.
- Update the independent Cloudflare cron tooling lockfile and audit its dependencies and credential-free bundle separately in CI.
- Stage private PostgreSQL bootstrap inputs for its service user and prevent an encrypted-backup output creation race.

- Serialize borrower allowance and shared-container lending decisions and reject ineligible borrowers.
- Block mapped IPv6 webhook targets and update Nodemailer for security fixes.
- Supervise the complete local stack with private storage, captured email, durable Redis and local service guards.
- Restore the complete application schema atomically, remove newer-release table residue, keep failed post-commit recoveries offline and tear down inactive layout services.
- Preserve dropdown labels and verify full Linux browser, private-storage and encrypted recovery journeys with real queued SMTP before and after restore.
- Pin the migration CLI security override and bound production image build concurrency.
- Keep mobile transfer references on one line so they do not overlap direction captions.
- Remove the unused global npm package manager from published runtime images while retaining Node and the isolated migration CLI.
- Pin the coordinated October 26 Silo server and client release by immutable multi-architecture digest across local, production and CI storage.
- Build the bundled Caddy edge with a patched Go toolchain, locked dependencies and Alpine security updates, and propagate installer build failures.

## [0.5.0] - Unreleased

The first public release: everything since the split from SpaceWorks
Expand Down
21 changes: 16 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ CMD ["node", "scripts/prisma.mjs", "migrate", "deploy"]
FROM node:22-alpine AS prisma-cli
WORKDIR /opt/prisma-cli
COPY --from=deps /app/node_modules/prisma/package.json /tmp/prisma-package.json
RUN npm install --omit=dev --no-save --no-audit --no-fund \
COPY package.json /tmp/application-package.json
# Keep the isolated migration CLI's transitive security pins aligned with the app.
# Copy only dependency overrides; application scripts/workspaces do not run here.
RUN node -e 'const fs=require("node:fs"),p=require("/tmp/application-package.json");fs.writeFileSync("package.json",JSON.stringify({private:true,overrides:p.overrides||{}}))' \
&& npm install --omit=dev --no-save --no-audit --no-fund \
"prisma@$(node -p "require('/tmp/prisma-package.json').version")" \
&& npm cache clean --force

Expand All @@ -40,10 +44,12 @@ WORKDIR /opt/ops
COPY --from=deps /app/node_modules/ioredis/package.json /tmp/ioredis-package.json
COPY --from=deps /app/node_modules/@aws-sdk/client-s3/package.json /tmp/s3-package.json
COPY --from=deps /app/node_modules/nodemailer/package.json /tmp/nodemailer-package.json
COPY --from=deps /app/node_modules/bullmq/package.json /tmp/bullmq-package.json
RUN npm install --omit=dev --no-save --no-audit --no-fund \
"ioredis@$(node -p "require('/tmp/ioredis-package.json').version")" \
"@aws-sdk/client-s3@$(node -p "require('/tmp/s3-package.json').version")" \
"nodemailer@$(node -p "require('/tmp/nodemailer-package.json').version")" \
"bullmq@$(node -p "require('/tmp/bullmq-package.json').version")" \
&& npm cache clean --force

# Stage 2: Rebuild the source code
Expand Down Expand Up @@ -122,6 +128,8 @@ COPY --from=frontend-builder --chown=nextjs:nodejs /app/frontend/.next/static ./
COPY --from=frontend-builder --chown=nextjs:nodejs /app/frontend/public ./frontend/public
COPY --chmod=755 scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN rm -f frontend/.env frontend/.env.local frontend/.env.production .env
# Runtime roles execute Node directly; dependency installation stays in build stages.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
USER nextjs
EXPOSE 3000
ENV PORT=3000
Expand Down Expand Up @@ -149,7 +157,7 @@ RUN apk add --no-cache font-dejavu fontconfig openssl && fc-cache -f
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

RUN mkdir .next
RUN chown nextjs:nodejs .next
Expand All @@ -163,15 +171,18 @@ COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
# Prisma CLI for the optional MIGRATE_ON_START=1 path (root-owned, read-only).
COPY --from=prisma-cli /opt/prisma-cli/node_modules /opt/prisma-cli/node_modules
COPY --chmod=755 scripts/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Operator tools (setup, preflight, schema checks, smoke tests), root-owned.
COPY scripts/*.mjs ./scripts/
COPY docker/vps ./docker/vps
# Operator sources stay readable when the installer uses private file modes.
# Tools needing configuration writes still run through the root operator role.
COPY --chown=nextjs:nodejs scripts/*.mjs ./scripts/
COPY --chown=nextjs:nodejs docker/vps ./docker/vps
COPY --from=ops-modules /opt/ops/node_modules ./docker/vps/node_modules

# Next's standalone trace may include dotenv files when a build is performed
# from a developer checkout. Secrets belong only in the runtime environment.
RUN rm -f .env .env.local .env.production .env.development

# Migrations use the isolated Prisma CLI through Node, without a package manager.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
USER nextjs

EXPOSE 3000
Expand Down
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,12 @@ hostnames, one container on one hostname, or the website on Netlify), hostnames,
HTTPS, storage (bundled Silo or an external S3/R2 bucket), SMTP, backups and automatic deploys. One
summary lists every choice and **nothing changes until you confirm it**. Then it installs Docker if
needed, generates every secret without printing it, downloads the release's ready-made images from
[GitHub Packages](docs/CONTAINER_IMAGES.md), migrates and checks each service. Nothing is compiled on
your server.
[GitHub Packages](docs/CONTAINER_IMAGES.md), migrates and checks each service. The application images
are prebuilt; setup builds the bundled HTTPS edge from pinned Caddy sources and security updates.
An installation behind its own reverse proxy skips that build.

**Before you start:** 2 vCPU, 2 GB RAM and 30 GB disk, DNS records for your hostnames, inbound ports
**Before you start:** 2 vCPU, 4 GB RAM with swap available and 30 GB disk for the bundled edge,
DNS records for your hostnames, inbound ports
80 and 443, an SMTP relay on a verified sending domain, and your admin email. Phone cameras need HTTPS
to scan QR codes.

Expand Down Expand Up @@ -241,18 +243,25 @@ You need Node.js 22 and Docker (the development Compose file provides PostgreSQL
Mailpit).

```bash
npm ci
cp .env.example .env
docker compose up -d
npm run db:deploy
DEMO_PASSWORD='choose-12-or-more-characters' npm run db:seed:demo
npm run dev
./local.sh start
```

Open http://localhost:3000 and sign in as `anjali@tinkerspace.test` or `meera@tinkerspace.test`
(inventory managers), `joel@tinkerspace.test` (staff) or `fathima@tinkerspace.test` (member) with your
demo password. To try the first-run wizard instead, skip the seed and open `/setup`. Without SMTP,
development emails (magic links, invitations) are written to `.data/mail/`.
The helper generates local secrets, initializes the private Silo bucket, applies migrations, then
supervises the web app, BullMQ worker and beat. Open http://localhost:3000/setup to create your first
account and space. Email stays in local Mailpit at http://127.0.0.1:8025; Redis persists jobs with
append-only storage and refuses eviction. Ctrl+C stops all three Node processes; `./local.sh stop`
also stops the Docker services while keeping their data.

For Windows or manual startup: run `npm ci`, copy `.env.example` to `.env` and fill its empty auth
and encryption keys, then run `docker compose up -d --wait`, `npm run storage:init`,
`npm run db:deploy` and `npm run dev:stack`. The worker and beat health endpoints use separate
loopback ports 3460 and 3461 (overrides: `SWI_DEV_WORKER_PORT`, `SWI_DEV_BEAT_PORT`).
For demo accounts, stop the stack and run `DEMO_PASSWORD='choose-12-or-more-characters' npm run
db:seed:demo`, then restart it. Inventory managers: `anjali@tinkerspace.test` or
`meera@tinkerspace.test`; staff: `joel@tinkerspace.test`; member: `fathima@tinkerspace.test`.
`npm run dev` runs the web process alone for focused frontend work; background processing requires
`dev:stack` or separately running worker and beat. Existing `.env` files are preserved: point SMTP
at local Mailpit explicitly if they predate these defaults. Admin email settings override the env.

## Design system

Expand Down Expand Up @@ -300,7 +309,8 @@ How this app applies the brand is in [docs/DESIGN.md](docs/DESIGN.md). Rebuild t
## Common commands

```bash
npm run dev # development server
npm run dev:stack # web + BullMQ worker + beat (after local services setup)
npm run dev # web process only
npm run lint # ESLint (with the 300-line limit)
npm run typecheck # TypeScript
npm run test:unit # unit tests
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ the newest image) rather than patching an older version.

| Version | Supported |
| --- | --- |
| 0.1.x (latest release) | Yes |
| 0.6.x (latest release) | Yes |
| Earlier builds, pre-releases and `dev` | No |

## Security model in brief
Expand All @@ -44,7 +44,7 @@ permissions). In short:
archived and foreign spaces, records and QR codes all answer the same
not-found. A transfer between spaces checks each side against its own
membership.
- **Roles.** Owner, admin, inventory manager, staff and member, checked on the
- **Roles.** Inventory manager, staff, member and repair-only technician, checked on the
server for every request (`lib/roles.ts`); hiding a button is never the only
check. The instance admin area (`/admin`) needs a fresh authenticator code.
- **Sign-in.** Email and password, emailed single-use sign-in links and TOTP
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.0
0.6.0
2 changes: 1 addition & 1 deletion components/transfers/TransfersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function TransfersTable({ slug, rows }: { slug: string; rows: TransferRow
{rows.map((row) => (
<TR key={row.id}>
<TD>
<Link href={`/s/${slug}/transfers/${row.id}`} className="-my-3 inline-flex min-h-11 items-center align-middle font-semibold text-accent-text hover:underline">{row.reference}</Link>
<Link href={`/s/${slug}/transfers/${row.id}`} className="-my-3 inline-flex min-h-11 items-center whitespace-nowrap align-middle font-semibold text-accent-text hover:underline">{row.reference}</Link>
<span className="block text-caption text-ink-muted">{row.directionLabel}</span>
</TD>
<TD><TransferStatusChip status={row.status} /></TD>
Expand Down
4 changes: 3 additions & 1 deletion components/ui/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export function Field({ label, optional, helperText, error, hideLabel, className
// attributes after hydration, so the label would point nowhere. Re-wire it.
const box = useRef<HTMLDivElement>(null);
useEffect(() => {
const control = box.current?.querySelector<HTMLElement>("input, select, textarea, [role=combobox]");
// Dropdown keeps an aria-hidden native select before its visible trigger.
// Giving that select the trigger's id would duplicate it and steal the label.
const control = box.current?.querySelector<HTMLElement>("input:not([aria-hidden=true]), select:not([aria-hidden=true]), textarea:not([aria-hidden=true]), [role=combobox]:not([aria-hidden=true])");
if (!control || control.id) return;
control.id = controlId;
if (describedBy && !control.getAttribute("aria-describedby")) control.setAttribute("aria-describedby", describedBy);
Expand Down
2 changes: 1 addition & 1 deletion compose.internal-tls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ services:
SWI_CADDY_OPTIONS: local_certs
# Hands the frontend only the local CA's public certificate, never its keys.
caddy-root:
image: caddy:2-alpine
image: spaceworks-caddy:2.11.7-security-20261029
profiles: [split]
restart: "no"
network_mode: none
Expand Down
Loading
Loading