Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/better-near-auth-1-10-0-ephemeral-marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"everything-dev": patch
---

Bump `better-near-auth` from 1.9.0 to 1.10.0 across the catalog and pin it exactly. The new release adds an optional `ephemeral?: true` marker on `RelayerEphemeralConfig`; mark the SIWN relayer block in `bos.config.json → app.auth.variables.siwn` with that explicit flag while keeping the existing `whitelistedContracts`, `maxGasPerTransaction`, and `maxDepositPerTransaction` constraints. Runtime behavior is unchanged — the marker is documentation only and `getRelayerInfo().mode` still reports `"ephemeral"`.
9 changes: 9 additions & 0 deletions .changeset/bos-typecheck-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"everything-dev": minor
---

Add a `bos typecheck` command that runs TypeScript type checking across all local workspaces (host, ui, api, auth, and every plugin with a `tsconfig.json`), streaming errors inline and failing with a non-zero exit if any workspace fails. Root `bun run typecheck` now delegates to it.

- New `bos typecheck [packages]` aggregates pass/fail across all configured local workspaces instead of stopping at the first error.
- Framework source hardened for strict consumer configs (`noUncheckedIndexedAccess`): safe non-null assertions in `contract.ts`, `fastkv.ts`, and `api-contract.ts`.
- `plugins/apps` tsconfig aligned with the plugin template (`types: ["node"]`, DOM lib) so its typecheck passes.
5 changes: 5 additions & 0 deletions .changeset/curly-ghosts-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"everything-dev": patch
---

Fix `bos sync` leaking parent-only root `package.json` scripts into child projects. When syncing the root package, scripts are now filtered to the child-appropriate set generated by `buildChildRootScripts`, so parent-specific commands (regression tests, etc.) no longer appear in child projects and child script values (e.g. `typecheck`) stay correct.
10 changes: 10 additions & 0 deletions .changeset/deploy-lock-and-infra-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"everything-dev": patch
---

Remove deploy lock feature and add `bos infra export` command.

- Removed `bos deploy lock acquire/release/inspect` commands and FastKV-backed deploy lock logic. Concurrent deploys now follow last-write-wins semantics (harmless redundancy for Railway redeploy).
- `bos infra export [--target ci|local] [--network mainnet|testnet]` emits `{env, services, account, gateway, project, generatedAt}` JSON. The deploy workflow consumes this to populate `$GITHUB_ENV` instead of repeating `API_DATABASE_URL`, `AUTH_DATABASE_URL`, and `CORS_ORIGIN` literals. Host port comes from `BOS_CI_HOST_PORT` env or `runtimeConfig.host.port`.
- `buildOriginMap` now derives plugin origins from `runtimeConfig.plugins[id].extendsRef` / `runtimeConfig.auth?.extendsRef` (already populated by `loadResolvedConfig`), removing the duplicate raw-JSON read and the parent-runtime fallback logic.
- New tests cover the CI plan builder and the resolved-config origin lookup.
4 changes: 4 additions & 0 deletions .changeset/four-bags-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Add a `backcompat` regression test mode (`bun run test:regression:backcompat`) that boots a local host while loading the last published UI/API/plugin bundles via Module Federation, verifying the new host works against existing published bundles. Regression commands (`dev`, `prod`, `backcompat`) now run both HTTP and browser suites each time instead of stopping when the HTTP suite fails.
8 changes: 8 additions & 0 deletions .changeset/siwn-ephemeral-relayer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
---

Confirm the SIWN auth relayer is in `RelayerEphemeralConfig` ("Ephemeral with settings") mode: a rich-object `relayer` block in `bos.config.json → app.auth.variables.siwn` with `whitelistedContracts`, `maxGasPerTransaction`, and `maxDepositPerTransaction` and no `accountId` / `privateKey`. better-near-auth 1.9.0's `initRelayer` resolves this to an auto-generated ED25519 keypair on first startup, encrypted with `BETTER_AUTH_SECRET` (HKDF-SHA256 → AES-256-GCM) and persisted in the `relayerKey` table.

The vestigial `NEAR_RELAYER_PRIVATE_KEY` line is removed from `.env.example` in favor of an inline comment pointing operators at `/admin/relayer` (which surfaces a "needs funding" prompt using `getRelayerInfo().enabled === false` once the auto-generated implicit account has zero balance). Operators funding the implicit account via `authClient.near.getNearClient().transfer()` enables relay without ever leaving the existing ephemeral-mode config.

AGENTS.md gains a "SIWN Auth Relayer" subsection under "Common Patterns" documenting the operational rules (funding flow, parent-key requirement for sub-account creation, why the implicit relayer account can't own sub-accounts, and the path back to `RelayerExplicitConfig` if a named-account relayer is needed).
9 changes: 9 additions & 0 deletions .changeset/ui-anon-orgs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"ui": minor
---

Add a dedicated anonymous mount and reorganize organization routes.

- Add a `/_layout/_anon` pathless layout for pre-auth pages. Move login from the public layout into it; the layout redirects authenticated users to `/dashboard` and provides the theme toggle header.
- Rename the organization route group from `/organizations` to `/orgs` (`/orgs`, `/orgs/new`, `/orgs/$slug`) and move invitation acceptance to `/orgs/invites/$id`.
- Remove the stale nostr entry from the authenticated sidebar.
9 changes: 9 additions & 0 deletions .changeset/ui-layout-mounts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"ui": minor
---

Reorganize UI routes into mount-point layouts and rename the authenticated workspace.

- Move admin routes under a new `/_layout/_admin` pathless layout that gates on the admin role and redirects non-admins to `/dashboard`. The tenant admin dashboard (`admin/admin/index.tsx`) and system page (`admin/admin/system.tsx`) now render as children of the admin layout through an `Outlet`.
- Rename the authenticated `/home` route to `/dashboard`, updating the sidebar, mobile tab bar, user nav, and login redirect fallbacks.
- Move the apps and things routes under the public layout (`_layout/_public/apps`, `_layout/_public/things`) so they render inside the shared public shell instead of the top-level layout.
12 changes: 3 additions & 9 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@
# Update values as needed for your local environment

# app.host
CORS_ORIGIN=http://localhost:4100
TENANT_WHITELIST=
ALLOW_OVERRIDE=
ALLOW_UNTRUSTED_SSR=
CORS_ORIGIN=http://localhost:3000
CSP_STRICT=

# app.api
API_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5432/api_db

# app.auth
NEAR_SUB_ACCOUNT_PARENT_KEY_MAINNET=
NEAR_SUB_ACCOUNT_PARENT_KEY_TESTNET=
AUTH_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5433/auth_db
BETTER_AUTH_SECRET=
GITHUB_CLIENT_SECRET=
Expand All @@ -22,9 +21,4 @@ TWILIO_AUTH_TOKEN=
TWILIO_PHONE_NUMBER=
RESEND_API_KEY=
NEAR_RELAYER_PRIVATE_KEY=
NEAR_SUB_ACCOUNT_PARENT_KEY_MAINNET=
NEAR_SUB_ACCOUNT_PARENT_KEY_TESTNET=

# plugins.template
TEMPLATE_DATABASE_URL=postgres://everythingdev:everythingdev@localhost:5434/template_db

12 changes: 9 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ jobs:
ZE_USER_EMAIL: ${{ secrets.ZEPHYR_USER_EMAIL }}
RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
TARGET_BRANCH: ${{ github.ref_name }}
API_DATABASE_URL: postgres://everythingdev:everythingdev@127.0.0.1:5432/api_db
AUTH_DATABASE_URL: postgres://everythingdev:everythingdev@127.0.0.1:5433/auth_db
CORS_ORIGIN: http://127.0.0.1:4100
BOS_CI_HOST_PORT: "4100"
services:
postgres-api:
image: postgres:17-alpine
Expand Down Expand Up @@ -95,6 +93,14 @@ jobs:
run: |
echo "ZE_CI_TOKEN=$ZEPHYR_CI_TOKEN" >> "$GITHUB_ENV"

- name: Emit CI infra plan
id: infra
run: |
bun run bos infra export --target ci --network mainnet > .ci-infra.json
jq -r '.env | to_entries[] | "\(.key)=\(.value)"' .ci-infra.json >> "$GITHUB_ENV"
echo "## CI infra plan" >> "$GITHUB_STEP_SUMMARY"
jq -r '"Services: " + (.services | length | tostring) + "\nEnv vars: " + (.env | keys | length | tostring)' .ci-infra.json >> "$GITHUB_STEP_SUMMARY"

- name: Publish with deploy
timeout-minutes: 20
run: bun run bos publish --deploy${{ inputs.verbose == true && ' --verbose' || '' }}
Expand Down
17 changes: 16 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ Current fixed-core host rules:
- the shared host still boots once from one base runtime snapshot
- child runtime config must extend the active BOS runtime
- supported request-scoped overrides are `ui` and existing `plugins.<id>.ui`
- tenant SSR is gated by `TENANT_WHITELIST` and `ALLOW_UNTRUSTED_SSR`
- tenant SSR is gated per-tenant by the `allowSsr` column on the tenant record; the host's BindingResolver reads permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s)
- nested label routing and account-relative tenant derivation are the intended architecture direction, but not the complete resolver behavior today

For full per-request host/plugin/auth/api swapping, start from `plans/runtime-config-hot-swap.md`.
Expand Down Expand Up @@ -351,6 +351,21 @@ const { runtimeConfig } = Route.useLoaderData();
const appName = getActiveRuntime(runtimeConfig)?.title ?? getAccount(runtimeConfig);
```

### SIWN Auth Relayer (gasless NEP-366 relay)

The auth plugin's `siwn({ relayer: ... })` block in `bos.config.json → app.auth.variables.siwn` is **ephemeral mode** — the rich-object shape with `whitelistedContracts`, `maxGasPerTransaction`, and `maxDepositPerTransaction` but no `accountId` / `privateKey`. From the better-near-auth skill: that's `RelayerEphemeralConfig` ("Ephemeral with settings").

**Operational rules:**

- On first startup the server generates an ED25519 keypair per network, derives an implicit hex account from the public key, and encrypts the private key with `BETTER_AUTH_SECRET` (HKDF-SHA256 → AES-256-GCM) into the `relayerKey` table. Same keypair recovers on every restart.
- After first startup the server logs the implicit account id. **Fund that account with NEAR** to enable relay — otherwise every relay attempt fails with insufficient balance from the RPC.
- Funding workflow: admins hit `getRelayerInfo`; the `/admin/relayer` page surfaces a "needs funding" banner on `/admin` when `enabled === false` and `accountId` is set, then the admin's connected wallet transfers NEAR to the implicit account via `authClient.near.getNearClient().transfer()`.
- The implicit relayer account is *not* a `.near` named account, so it cannot own sub-accounts. `siwn.subAccount.parentAccount` must be a named account (this project uses `v1.citynode.near` / `v1.citynode.testnet`), and the parent key is supplied via `NEAR_SUB_ACCOUNT_PARENT_KEY_MAINNET` / `NEAR_SUB_ACCOUNT_PARENT_KEY_TESTNET` secrets. Without the parent key the sub-account endpoint explains why in the error message and returns a `not-configured` reason.
- `NEAR_RELAYER_PRIVATE_KEY` is vestigial in ephemeral mode and is omitted from `.env.example`. Only reintroduce (plus explicit `relayer: { accountId, privateKey }`) when moving to `RelayerExplicitConfig`.
- The mode is observable at runtime: `getRelayerInfo()` returns `{ accountId, mode: "ephemeral", publicKey, balance, enabled }`.

To switch to `RelayerExplicitConfig`, replace the rich-object shape with `relayer: { accountId: "relayer.<your-domain>.near", privateKey: process.env.RELAYER_PRIVATE_KEY, whitelistedContracts: [...], maxGasPerTransaction: "...", maxDepositPerTransaction: "0" }` and re-add the env var. The ephemeral key in the `relayerKey` table is ignored once an explicit key is provided.

## Security

### Shared Singleton Trust Model
Expand Down
2 changes: 1 addition & 1 deletion LLM.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Practical consequence:
- Path-based runtime metadata works today.
- Shared-host tenant UI mode now works today: the host can resolve a tenant config per request, enforce that it extends the base BOS runtime, and apply request-scoped UI overrides while keeping auth/API/plugin routers fixed.
- Supported tenant overrides today are `app.ui`, existing `plugins.<id>.ui`, and existing `plugins.<id>.sidebar`.
- Tenant SSR is gated by `TENANT_WHITELIST` and `ALLOW_UNTRUSTED_SSR`.
- Tenant SSR is gated per-tenant by the `allow_ssr` column on the tenant record; the host's BindingResolver reads these permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s).
- True wildcard-domain full runtime swapping for host/plugin/auth/api still needs dynamic scoped app rebuilding in the host.
- The active design doc for that work is `plans/runtime-config-hot-swap.md`.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ What works today:
- `app.ui`
- existing `plugins.<pluginId>.ui`
- existing `plugins.<pluginId>.sidebar`
- Tenant SSR is gated by `TENANT_WHITELIST` and `ALLOW_UNTRUSTED_SSR`.
- Tenant SSR is gated per-tenant by the `allow_ssr` column on the tenant record; the host's BindingResolver reads these permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s).

Design direction:
- nested labels compose onto the active runtime account, such as `chicago.pizza.com -> bos://chicago.pizza.pingpayio.near/pizza.com`
Expand Down
94 changes: 93 additions & 1 deletion api/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,45 @@ export const TenantSchema = z.object({
id: z.string(),
subdomain: z.string(),
accountId: z.string(),
orgId: z.string(),
orgId: z.string().nullable(),
name: z.string(),
status: TenantStatusSchema,
allowUiOverrides: z.boolean(),
allowBackendOverrides: z.boolean(),
allowSsr: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string().nullable(),
});

export type Tenant = z.infer<typeof TenantSchema>;

export const TenantBindingSchema = z.object({
hostname: z
.string()
.describe("Subdomain hostname that routes to this tenant on the parent domain"),
accountId: z.string(),
allowUiOverrides: z.boolean(),
allowBackendOverrides: z.boolean(),
allowSsr: z.boolean(),
status: TenantStatusSchema,
});

export const CityNodeSchema = z.object({
id: z.string(),
tenantId: z.string(),
orgId: z.string(),
validatorPool: z.string(),
hostname: z.string(),
accountId: z.string(),
name: z.string(),
tenantStatus: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
});

export type CityNode = z.infer<typeof CityNodeSchema>;

const ThingSchema = z.object({
thingId: z.string().describe("Unique identifier for the thing"),
type: z.string().describe("Plugin-derived thing type"),
Expand Down Expand Up @@ -80,6 +109,9 @@ export const contract = oc.router({
name: z.string(),
accountId: z.string(),
status: z.enum(["active", "pending"]).optional(),
allowUiOverrides: z.boolean().default(true),
allowBackendOverrides: z.boolean().default(false),
allowSsr: z.boolean().default(false),
}),
)
.output(TenantSchema)
Expand All @@ -94,6 +126,9 @@ export const contract = oc.router({
subdomain: z.string().optional(),
accountId: z.string().optional(),
status: TenantStatusSchema.optional(),
allowUiOverrides: z.boolean().optional(),
allowBackendOverrides: z.boolean().optional(),
allowSsr: z.boolean().optional(),
}),
)
.output(TenantSchema)
Expand Down Expand Up @@ -128,6 +163,16 @@ export const contract = oc.router({
.output(TenantSchema)
.errors({ NOT_FOUND }),

listTenantBindings: oc
.route({
method: "GET",
path: "/tenants/bindings",
summary: "List all active tenant domain bindings",
description:
"Public — returns the subdomain-to-config mapping used by the host's BindingResolver.",
})
.output(z.array(TenantBindingSchema)),

tenantPreflight: oc
.route({ method: "POST", path: "/tenants/preflight" })
.input(
Expand All @@ -150,6 +195,53 @@ export const contract = oc.router({
)
.errors({ UNAUTHORIZED, BAD_REQUEST }),

listCityNodes: oc
.route({
method: "GET",
path: "/citynodes",
summary: "List all city nodes",
description: "Public — returns all city nodes with their linked tenant bindings.",
})
.output(z.array(CityNodeSchema)),

resolveCityNode: oc
.route({
method: "GET",
path: "/citynodes/resolve",
summary: "Resolve a city node by tenant account ID",
description: "Public — returns the city node for a tenant account (used by the stake route).",
})
.input(z.object({ accountId: z.string() }))
.output(CityNodeSchema.nullable()),

createCityNode: oc
.route({ method: "POST", path: "/citynodes" })
.input(
z.object({
tenantId: z.string(),
validatorPool: z.string(),
}),
)
.output(CityNodeSchema)
.errors({ UNAUTHORIZED, FORBIDDEN, BAD_REQUEST }),

updateCityNode: oc
.route({ method: "PATCH", path: "/citynodes/{cityNodeId}" })
.input(
z.object({
cityNodeId: z.string(),
validatorPool: z.string().optional(),
}),
)
.output(CityNodeSchema)
.errors({ UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST }),

deleteCityNode: oc
.route({ method: "POST", path: "/citynodes/{cityNodeId}/delete" })
.input(z.object({ cityNodeId: z.string() }))
.output(z.object({ success: z.literal(true) }))
.errors({ UNAUTHORIZED, FORBIDDEN, NOT_FOUND }),

createThing: oc
.route({
method: "POST",
Expand Down
3 changes: 3 additions & 0 deletions api/src/db/migrations/0002_awesome_madame_web.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "tenants" ADD COLUMN "allow_ui_overrides" boolean DEFAULT true NOT NULL;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "allow_backend_overrides" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "tenants" ADD COLUMN "allow_ssr" boolean DEFAULT false NOT NULL;
1 change: 1 addition & 0 deletions api/src/db/migrations/0003_brief_freak.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "tenants" ALTER COLUMN "org_id" DROP NOT NULL;
11 changes: 11 additions & 0 deletions api/src/db/migrations/0004_majestic_jamie_braddock.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE "citynodes" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" uuid NOT NULL,
"org_id" text NOT NULL,
"validator_pool" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "citynodes_tenant_id_unique" UNIQUE("tenant_id")
);
--> statement-breakpoint
ALTER TABLE "citynodes" ADD CONSTRAINT "citynodes_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE no action;
Loading
Loading