diff --git a/.changeset/bos-typecheck-command.md b/.changeset/bos-typecheck-command.md new file mode 100644 index 00000000..4d3420e8 --- /dev/null +++ b/.changeset/bos-typecheck-command.md @@ -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. diff --git a/.changeset/curly-ghosts-sync.md b/.changeset/curly-ghosts-sync.md new file mode 100644 index 00000000..f11913c4 --- /dev/null +++ b/.changeset/curly-ghosts-sync.md @@ -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. diff --git a/.changeset/four-bags-hear.md b/.changeset/four-bags-hear.md new file mode 100644 index 00000000..c6db30fd --- /dev/null +++ b/.changeset/four-bags-hear.md @@ -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. diff --git a/.changeset/ui-anon-orgs.md b/.changeset/ui-anon-orgs.md new file mode 100644 index 00000000..d1e2a805 --- /dev/null +++ b/.changeset/ui-anon-orgs.md @@ -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. \ No newline at end of file diff --git a/.changeset/ui-layout-mounts.md b/.changeset/ui-layout-mounts.md new file mode 100644 index 00000000..b9e8dd19 --- /dev/null +++ b/.changeset/ui-layout-mounts.md @@ -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. diff --git a/.env.example b/.env.example index 450772d4..e9e9b5e3 100644 --- a/.env.example +++ b/.env.example @@ -3,15 +3,14 @@ # app.host CORS_ORIGIN=http://localhost:4100 -TENANT_WHITELIST= -ALLOW_OVERRIDE= -ALLOW_UNTRUSTED_SSR= 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= @@ -22,8 +21,6 @@ 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 diff --git a/AGENTS.md b/AGENTS.md index 8453be7f..ddca75f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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..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`. diff --git a/LLM.txt b/LLM.txt index 3b5f2820..5eb2fff3 100644 --- a/LLM.txt +++ b/LLM.txt @@ -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..ui`, and existing `plugins..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`. diff --git a/README.md b/README.md index 25b62e71..cf47c93f 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ What works today: - `app.ui` - existing `plugins..ui` - existing `plugins..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` diff --git a/api/src/contract.ts b/api/src/contract.ts index 5887408c..3c9cd6e8 100644 --- a/api/src/contract.ts +++ b/api/src/contract.ts @@ -17,9 +17,12 @@ 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(), @@ -27,6 +30,17 @@ export const TenantSchema = z.object({ export type Tenant = z.infer; +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, +}); + const ThingSchema = z.object({ thingId: z.string().describe("Unique identifier for the thing"), type: z.string().describe("Plugin-derived thing type"), @@ -80,6 +94,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) @@ -94,6 +111,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) @@ -128,6 +148,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( diff --git a/api/src/db/migrations/0002_awesome_madame_web.sql b/api/src/db/migrations/0002_awesome_madame_web.sql new file mode 100644 index 00000000..e39a3327 --- /dev/null +++ b/api/src/db/migrations/0002_awesome_madame_web.sql @@ -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; \ No newline at end of file diff --git a/api/src/db/migrations/0003_brief_freak.sql b/api/src/db/migrations/0003_brief_freak.sql new file mode 100644 index 00000000..1a294453 --- /dev/null +++ b/api/src/db/migrations/0003_brief_freak.sql @@ -0,0 +1 @@ +ALTER TABLE "tenants" ALTER COLUMN "org_id" DROP NOT NULL; \ No newline at end of file diff --git a/api/src/db/migrations/meta/0002_snapshot.json b/api/src/db/migrations/meta/0002_snapshot.json new file mode 100644 index 00000000..4351ee45 --- /dev/null +++ b/api/src/db/migrations/meta/0002_snapshot.json @@ -0,0 +1,165 @@ +{ + "id": "7b8a421a-d0c7-414a-96e8-0c259935178b", + "prevId": "3e88c17e-53d5-4809-b872-33d2f87a8a90", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "allow_ui_overrides": { + "name": "allow_ui_overrides", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_backend_overrides": { + "name": "allow_backend_overrides", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_ssr": { + "name": "allow_ssr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tenants_subdomain_idx": { + "name": "tenants_subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tenants_account_id_idx": { + "name": "tenants_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_subdomain_unique": { + "name": "tenants_subdomain_unique", + "nullsNotDistinct": false, + "columns": ["subdomain"] + }, + "tenants_account_id_unique": { + "name": "tenants_account_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id"] + }, + "tenants_org_id_unique": { + "name": "tenants_org_id_unique", + "nullsNotDistinct": false, + "columns": ["org_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": ["active", "pending", "suspended", "pending_deletion"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/src/db/migrations/meta/0003_snapshot.json b/api/src/db/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..b013ab2b --- /dev/null +++ b/api/src/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,165 @@ +{ + "id": "85c9d130-a4b6-4026-a2df-890fddfa7518", + "prevId": "7b8a421a-d0c7-414a-96e8-0c259935178b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subdomain": { + "name": "subdomain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "tenant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "allow_ui_overrides": { + "name": "allow_ui_overrides", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "allow_backend_overrides": { + "name": "allow_backend_overrides", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_ssr": { + "name": "allow_ssr", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tenants_subdomain_idx": { + "name": "tenants_subdomain_idx", + "columns": [ + { + "expression": "subdomain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tenants_account_id_idx": { + "name": "tenants_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_subdomain_unique": { + "name": "tenants_subdomain_unique", + "nullsNotDistinct": false, + "columns": ["subdomain"] + }, + "tenants_account_id_unique": { + "name": "tenants_account_id_unique", + "nullsNotDistinct": false, + "columns": ["account_id"] + }, + "tenants_org_id_unique": { + "name": "tenants_org_id_unique", + "nullsNotDistinct": false, + "columns": ["org_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tenant_status": { + "name": "tenant_status", + "schema": "public", + "values": ["active", "pending", "suspended", "pending_deletion"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/api/src/db/migrations/meta/_journal.json b/api/src/db/migrations/meta/_journal.json index c939ec4a..74597e55 100644 --- a/api/src/db/migrations/meta/_journal.json +++ b/api/src/db/migrations/meta/_journal.json @@ -15,6 +15,20 @@ "when": 1786078137908, "tag": "0001_brief_magik", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1786645659094, + "tag": "0002_awesome_madame_web", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1786653436589, + "tag": "0003_brief_freak", + "breakpoints": true } ] } diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index b141f76b..24aedf51 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { boolean, pgEnum, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; export const tenantStatus = pgEnum("tenant_status", [ "active", @@ -13,9 +13,12 @@ export const tenants = pgTable( id: uuid("id").defaultRandom().primaryKey(), subdomain: text("subdomain").notNull().unique(), accountId: text("account_id").notNull().unique(), - orgId: text("org_id").notNull().unique(), + orgId: text("org_id").unique(), name: text("name").notNull(), status: tenantStatus("status").default("active").notNull(), + allowUiOverrides: boolean("allow_ui_overrides").default(true).notNull(), + allowBackendOverrides: boolean("allow_backend_overrides").default(false).notNull(), + allowSsr: boolean("allow_ssr").default(false).notNull(), createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), deletedAt: timestamp("deleted_at", { mode: "date", withTimezone: true }), diff --git a/api/src/index.ts b/api/src/index.ts index df235000..cf2d04d0 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -89,7 +89,10 @@ export default createPlugin.withPlugins()({ const authorizedTenant = async ( input: { tenantId: string }, - context: { organization: { activeOrganizationId: string } }, + context: { + organization: { activeOrganizationId: string }; + near?: { primaryAccountId: string | null }; + }, ) => { const activeOrgId = context.organization.activeOrganizationId; const tenant = await services.tenants.resolveTenantById(input.tenantId); @@ -99,6 +102,16 @@ export default createPlugin.withPlugins()({ data: { resource: "tenant", resourceId: input.tenantId }, }); } + if (tenant.orgId === null) { + const isOwner = + !!context.near?.primaryAccountId && context.near.primaryAccountId === tenant.accountId; + if (!isOwner) { + throw new ORPCError("FORBIDDEN", { + message: "You do not own this personal tenant", + }); + } + return tenant; + } if (tenant.orgId !== activeOrgId) { throw new ORPCError("FORBIDDEN", { message: "You are not a member of this tenant's organization", @@ -144,6 +157,9 @@ export default createPlugin.withPlugins()({ accountId: input.accountId, orgId: context.organization.activeOrganizationId, status: input.status, + allowUiOverrides: input.allowUiOverrides, + allowBackendOverrides: input.allowBackendOverrides, + allowSsr: input.allowSsr, }); }), @@ -159,6 +175,9 @@ export default createPlugin.withPlugins()({ subdomain: input.subdomain, accountId: input.accountId, status: input.status, + allowUiOverrides: input.allowUiOverrides, + allowBackendOverrides: input.allowBackendOverrides, + allowSsr: input.allowSsr, }); }), @@ -223,6 +242,10 @@ export default createPlugin.withPlugins()({ return tenant; }), + listTenantBindings: builder.listTenantBindings.handler(async () => + services.tenants.listBindings(), + ), + tenantPreflight: builder.tenantPreflight.use(requireAuth).handler(async ({ input }) => { const subdomainValid = SUBDOMAIN_SEGMENT_REGEX.test(input.subdomain); const accountId = `${input.subdomain}.${input.parentAccount}`; diff --git a/api/src/services/tenants.ts b/api/src/services/tenants.ts index 2754489d..34f6231b 100644 --- a/api/src/services/tenants.ts +++ b/api/src/services/tenants.ts @@ -10,28 +10,55 @@ export interface TenantRecord { id: string; subdomain: string; accountId: string; - orgId: string; + orgId: string | null; name: string; status: TenantStatus; + allowUiOverrides: boolean; + allowBackendOverrides: boolean; + allowSsr: boolean; createdAt: string; updatedAt: string; deletedAt: string | null; } +export interface TenantBinding { + hostname: string; + accountId: string; + allowUiOverrides: boolean; + allowBackendOverrides: boolean; + allowSsr: boolean; + status: TenantStatus; +} + export interface TenantInput { subdomain: string; name: string; accountId: string; - orgId: string; + orgId: string | null; status?: TenantStatus; + allowUiOverrides?: boolean; + allowBackendOverrides?: boolean; + allowSsr?: boolean; } export interface TenantsService { listTenantsByOrgIds(orgIds: string[]): Promise; + listBindings(): Promise; createTenant(input: TenantInput): Promise; updateTenant( id: string, - input: Partial>, + input: Partial< + Pick< + TenantInput, + | "name" + | "subdomain" + | "accountId" + | "status" + | "allowUiOverrides" + | "allowBackendOverrides" + | "allowSsr" + > + >, ): Promise; softDeleteTenant(id: string): Promise; suspendTenant(id: string): Promise; @@ -55,6 +82,9 @@ function toTenantRecord(row: TenantRow): TenantRecord { orgId: row.orgId, name: row.name, status: row.status, + allowUiOverrides: row.allowUiOverrides, + allowBackendOverrides: row.allowBackendOverrides, + allowSsr: row.allowSsr, createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt), updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : String(row.updatedAt), deletedAt: row.deletedAt instanceof Date ? row.deletedAt.toISOString() : null, @@ -88,6 +118,27 @@ export const TenantsLive = Layer.effect( } }, + listBindings: async () => { + try { + const rows = await db + .select({ + subdomain: tenantsTable.subdomain, + accountId: tenantsTable.accountId, + allowUiOverrides: tenantsTable.allowUiOverrides, + allowBackendOverrides: tenantsTable.allowBackendOverrides, + allowSsr: tenantsTable.allowSsr, + status: tenantsTable.status, + }) + .from(tenantsTable); + return rows.map(({ subdomain, ...binding }) => ({ + ...binding, + hostname: subdomain, + })); + } catch (error) { + throw toOrpcError(error); + } + }, + createTenant: async (input) => { try { const [row] = await db @@ -98,6 +149,13 @@ export const TenantsLive = Layer.effect( accountId: input.accountId, orgId: input.orgId, ...(input.status !== undefined && { status: input.status }), + ...(input.allowUiOverrides !== undefined && { + allowUiOverrides: input.allowUiOverrides, + }), + ...(input.allowBackendOverrides !== undefined && { + allowBackendOverrides: input.allowBackendOverrides, + }), + ...(input.allowSsr !== undefined && { allowSsr: input.allowSsr }), }) .onConflictDoNothing() .returning(); diff --git a/api/tests/unit/tenants-service.test.ts b/api/tests/unit/tenants-service.test.ts index 427ad07f..fd04bd4b 100644 --- a/api/tests/unit/tenants-service.test.ts +++ b/api/tests/unit/tenants-service.test.ts @@ -147,6 +147,62 @@ describe("TenantsService", () => { expect(await runService(layer, (svc) => svc.listTenantsByOrgIds([]))).toEqual([]); }); + it("lists active and non-deleted bindings with default permissions", async () => { + const layer = freshLayer(); + await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + subdomain: "acme", + accountId: "acme.example.near", + }), + ); + const suspended = await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + subdomain: "beta", + accountId: "beta.example.near", + orgId: "org-2", + }), + ); + await runService(layer, (svc) => svc.suspendTenant(suspended.id)); + + const bindings = await runService(layer, (svc) => svc.listBindings()); + + expect(bindings).toHaveLength(2); + expect(bindings.find((b) => b.hostname === "acme")).toMatchObject({ + hostname: "acme", + accountId: "acme.example.near", + allowUiOverrides: true, + allowBackendOverrides: false, + allowSsr: false, + status: "active", + }); + expect(bindings.find((b) => b.hostname === "beta")?.status).toBe("suspended"); + }); + + it("persists allow_* overrides on create and update", async () => { + const layer = freshLayer(); + const created = await runService(layer, (svc) => + svc.createTenant({ + ...baseInput, + allowUiOverrides: false, + allowBackendOverrides: true, + allowSsr: true, + }), + ); + expect(created).toMatchObject({ + allowUiOverrides: false, + allowBackendOverrides: true, + allowSsr: true, + }); + + const updated = await runService(layer, (svc) => + svc.updateTenant(created.id, { allowSsr: false }), + ); + expect(updated.allowSsr).toBe(false); + expect(updated.allowUiOverrides).toBe(false); + }); + it("updates a tenant name", async () => { const layer = freshLayer(); const created = await runService(layer, (svc) => svc.createTenant(baseInput)); diff --git a/bos.config.json b/bos.config.json index b31993ec..7ffa5c2e 100644 --- a/bos.config.json +++ b/bos.config.json @@ -18,9 +18,6 @@ "production": "https://elliot-braem-7253-host-everything-dev-nearbuilder-aa7d0803c-ze.zephyrcloud.app", "secrets": [ "CORS_ORIGIN", - "TENANT_WHITELIST", - "ALLOW_OVERRIDE", - "ALLOW_UNTRUSTED_SSR", "CSP_STRICT" ] }, @@ -79,7 +76,7 @@ "registryNamespace": "dev.everything.near" }, "routes": [ - "ui/src/routes/_layout/apps/**" + "ui/src/routes/_layout/_public/apps/**" ], "integrity": "sha384-ehJsiIaaGQb5NjyNKWKS1+4ct479UK+n7MGaB6ZVdOsPSZd7hklMLZcIX5EkXECK" }, diff --git a/host/.env.example b/host/.env.example index 1283ab78..f5d82332 100644 --- a/host/.env.example +++ b/host/.env.example @@ -1,8 +1,5 @@ # app.host CORS_ORIGIN=http://localhost:3000 -TENANT_WHITELIST= -ALLOW_OVERRIDE= -ALLOW_UNTRUSTED_SSR= CSP_STRICT= # app.api diff --git a/host/README.md b/host/README.md index 41c8db50..2f47fe65 100644 --- a/host/README.md +++ b/host/README.md @@ -96,9 +96,6 @@ For the temporary publish registry, use `bos publish` or `bos publish --deploy`. | `API_SOURCE` | `local` or `remote` | Based on NODE_ENV | | `API_PROXY` | Proxy API requests to another host URL | - | | `NETWORK_ID` | Tenant account suffix resolution: `mainnet` or `testnet` | `mainnet` | -| `ALLOW_OVERRIDE` | Comma-separated tenant override targets like `ui`, `plugins.*`, `plugins.apps` | - | -| `TENANT_WHITELIST` | Comma-separated tenant account IDs allowed to SSR | - | -| `ALLOW_UNTRUSTED_SSR` | Allow tenant SSR without whitelist | `false` | | `HOST_DATABASE_URL` | SQLite database URL for auth | `file:./database.db` | | `HOST_DATABASE_AUTH_TOKEN` | Auth token for remote database | - | | `BETTER_AUTH_SECRET` | Secret for session encryption | - | @@ -112,7 +109,7 @@ For the temporary publish registry, use `bos publish` or `bos publish --deploy`. - Current: tenant config must extend the base BOS runtime - Current: tenant accounts derive relative to the active runtime account namespace - Current: supported tenant overrides are `app.ui`, existing `plugins..ui`, and existing `plugins..sidebar` -- Current: tenant SSR is opt-in via `TENANT_WHITELIST` or `ALLOW_UNTRUSTED_SSR=true` +- Current: tenant SSR is gated per-tenant by the `allowSsr` column on the tenant record; the host's BindingResolver reads these permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s) - Not yet implemented: tenant API/auth overrides in fixed-core mode - Not yet implemented: dynamic new plugin IDs per tenant @@ -123,9 +120,6 @@ Example deployment: ```bash BOS_ACCOUNT=linktree.near BOS_GATEWAY=linktree.com -ALLOW_OVERRIDE=ui,plugins.* -TENANT_WHITELIST=alice.linktree.near,bob.linktree.near -ALLOW_UNTRUSTED_SSR=false bos start --no-interactive ``` @@ -139,7 +133,8 @@ Example tenant behavior: Tenant config rules: - must extend the base BOS runtime -- may only override targets allowed by `ALLOW_OVERRIDE` +- may only override the tenant `ui` when `allow_ui_overrides` is true on the tenant record +- plugin UI overrides require `allow_backend_overrides` on the tenant record - in fixed-core mode, only UI-facing overrides are applied - custom UI remotes must provide integrity - custom plugin UI remotes must provide integrity @@ -147,9 +142,8 @@ Tenant config rules: Tenant SSR rules: -- if `ALLOW_UNTRUSTED_SSR=true`, any valid tenant UI with SSR config may SSR -- otherwise the tenant account must appear in `TENANT_WHITELIST` -- non-whitelisted tenants fall back to client rendering +- tenant SSR is allowed only when the tenant record has `allow_ssr` set +- tenants without `allow_ssr` fall back to client rendering ### Proxy Mode diff --git a/host/src/services/binding-resolver.ts b/host/src/services/binding-resolver.ts new file mode 100644 index 00000000..0a97472d --- /dev/null +++ b/host/src/services/binding-resolver.ts @@ -0,0 +1,141 @@ +import { logger } from "../utils/logger"; +import { resolveDomain } from "../utils/normalize"; +import type { RuntimeConfig } from "./config"; + +export type TenantBindingStatus = "active" | "pending" | "suspended" | "pending_deletion"; + +export interface TenantBinding { + hostname: string; + accountId: string; + allowUiOverrides: boolean; + allowBackendOverrides: boolean; + allowSsr: boolean; + status: TenantBindingStatus; +} + +const BINDINGS_TTL_MS = 30_000; + +interface CachedBindings { + apiUrl: string; + expiresAt: number; + entries: Map; + refetching?: Promise>; +} + +let bindingsCache: CachedBindings | null = null; + +export function clearBindingResolverCache() { + bindingsCache = null; +} + +function isBaseHost(hostname: string, gatewayId: string): boolean { + const normalized = hostname.toLowerCase(); + return normalized === gatewayId || normalized === "localhost" || normalized === "127.0.0.1"; +} + +async function fetchBindingsFromApi(apiUrl: string): Promise { + const endpoint = `${apiUrl.replace(/\/$/, "")}/api/tenants/bindings`; + let response: Response; + try { + response = await fetch(endpoint); + } catch (cause) { + throw new Error(`Failed to reach API at ${endpoint}: ${String(cause)}`, { cause }); + } + + if (!response.ok) { + throw new Error(`GET ${endpoint} failed with HTTP ${response.status}`); + } + + return (await response.json()) as TenantBinding[]; +} + +function resolveGatewayId(config: RuntimeConfig): string { + return resolveDomain(config.domain, config.host.url).toLowerCase(); +} + +function mapBindingsByHostname( + config: RuntimeConfig, + bindings: TenantBinding[], +): Map { + const gatewayId = resolveGatewayId(config); + const entries = new Map(); + for (const binding of bindings) { + entries.set(`${binding.hostname.toLowerCase()}.${gatewayId}`, binding); + } + return entries; +} + +function ensureBindingsLoaded(config: RuntimeConfig): Promise> { + const apiUrl = config.api?.url; + if (!apiUrl) { + return Promise.resolve(new Map()); + } + + const now = Date.now(); + if ( + bindingsCache && + bindingsCache.apiUrl === apiUrl && + bindingsCache.expiresAt > now && + bindingsCache.entries.size > 0 + ) { + return Promise.resolve(bindingsCache.entries); + } + + if (bindingsCache?.apiUrl === apiUrl && bindingsCache.refetching) { + return bindingsCache.refetching; + } + + const staleEntries = bindingsCache?.apiUrl === apiUrl ? bindingsCache.entries : null; + + const fetchPromise = fetchBindingsFromApi(apiUrl) + .then((bindings) => { + const entries = mapBindingsByHostname(config, bindings); + bindingsCache = { apiUrl, expiresAt: Date.now() + BINDINGS_TTL_MS, entries }; + return entries; + }) + .catch((cause) => { + if (staleEntries && staleEntries.size > 0) { + logger.error( + `[BindingResolver] Refresh failed, serving ${staleEntries.size} stale binding(s): ${cause instanceof Error ? cause.message : String(cause)}`, + ); + bindingsCache = { + apiUrl, + expiresAt: Date.now() + BINDINGS_TTL_MS, + entries: staleEntries, + }; + return staleEntries; + } + bindingsCache = null; + throw cause; + }); + + bindingsCache = { + apiUrl, + expiresAt: now + BINDINGS_TTL_MS, + entries: staleEntries ?? new Map(), + refetching: fetchPromise, + }; + + return fetchPromise; +} + +export interface BindingResolver { + resolve(hostname: string): Promise; + clear(): void; +} + +export function createBindingResolver(config: RuntimeConfig): BindingResolver { + return { + async resolve(hostname: string): Promise { + const normalized = hostname.toLowerCase(); + const gatewayId = resolveGatewayId(config); + if (isBaseHost(normalized, gatewayId)) { + return null; + } + + const entries = await ensureBindingsLoaded(config); + return entries.get(normalized) ?? null; + }, + clear: clearBindingResolverCache, + }; +} diff --git a/host/src/services/tenant-runtime.ts b/host/src/services/tenant-runtime.ts index 5909f09b..69c1aa2a 100644 --- a/host/src/services/tenant-runtime.ts +++ b/host/src/services/tenant-runtime.ts @@ -1,28 +1,25 @@ -import { - buildRuntimeConfig, - isRuntimeOverrideAllowed, - loadRemoteConfig, - parseRuntimeOverrideTargets, - type RuntimeConfig, -} from "everything-dev/config"; +import { buildRuntimeConfig, loadRemoteConfig, type RuntimeConfig } from "everything-dev/config"; import { verifySriForUrl } from "everything-dev/integrity"; import type { RuntimePlugin } from "../types"; import { logger } from "../utils/logger"; import { resolveDomain } from "../utils/normalize"; +import { + type BindingResolver, + clearBindingResolverCache, + createBindingResolver, +} from "./binding-resolver"; const REMOTE_CONFIG_TTL_MS = 30_000; const VERIFICATION_TTL_MS = 5 * 60_000; const MAX_REMOTE_CONFIG_CACHE_SIZE = 256; const MAX_VERIFICATION_CACHE_SIZE = 512; -const NEAR_ACCOUNT_ID_REGEX = - /^(?=.{2,64}$)([a-z0-9]+(?:[-_][a-z0-9]+)*)(\.([a-z0-9]+(?:[-_][a-z0-9]+)*))*$/; -type RuntimeOverrideTarget = ReturnType[number]; type BosEnv = "development" | "production" | "staging"; type IntegrityVerificationMode = "blocking" | "stale-while-revalidate"; interface ResolveRequestRuntimeOptions { verification?: IntegrityVerificationMode; + bindingResolver?: BindingResolver; } interface CachedRemoteConfig { @@ -55,9 +52,6 @@ export class TenantRuntimeError extends Error { const remoteConfigCache = new Map(); const verifiedUiCache = new Map(); -const unsupportedOverrideWarnings = new Set(); -let tenantWhitelistCache: { raw: string; value: Set } | null = null; -let allowedOverridesCache: { raw: string; value: RuntimeOverrideTarget[] } | null = null; function pruneExpiredCacheEntries( cache: Map, @@ -96,92 +90,7 @@ export function getTenantRuntimeErrorResponse(error: unknown): { status: number; export function clearTenantRuntimeCaches() { remoteConfigCache.clear(); verifiedUiCache.clear(); - unsupportedOverrideWarnings.clear(); - tenantWhitelistCache = null; - allowedOverridesCache = null; -} - -function parseBoolean(value: string | undefined): boolean { - if (!value) return false; - return ["1", "true", "yes", "on"].includes(value.toLowerCase()); -} - -function getTenantWhitelist(): Set { - const raw = process.env.TENANT_WHITELIST ?? ""; - if (tenantWhitelistCache?.raw === raw) { - return tenantWhitelistCache.value; - } - - const value = new Set( - raw - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - ); - tenantWhitelistCache = { raw, value }; - return value; -} - -function getAllowedOverrides(): RuntimeOverrideTarget[] { - const raw = process.env.ALLOW_OVERRIDE ?? ""; - if (allowedOverridesCache?.raw === raw) { - return allowedOverridesCache.value; - } - - const value = parseRuntimeOverrideTargets(raw); - allowedOverridesCache = { raw, value }; - return value; -} - -function warnUnsupportedOverrideTargets(targets: ReadonlyArray) { - for (const target of targets) { - if (target === "ui" || target === "plugins" || target.startsWith("plugins.")) { - continue; - } - - if (!unsupportedOverrideWarnings.has(target)) { - unsupportedOverrideWarnings.add(target); - logger.warn( - `[Tenant Runtime] Ignoring unsupported override target "${target}" in fixed-core mode`, - ); - } - } -} - -function resolveTenantAccountId( - hostname: string, - gatewayId: string, - namespaceAccountId: string, -): string | null { - const normalizedHost = hostname.toLowerCase(); - const normalizedGateway = gatewayId.toLowerCase(); - const normalizedNamespaceAccountId = namespaceAccountId.toLowerCase(); - - if ( - normalizedHost === normalizedGateway || - normalizedHost === "localhost" || - normalizedHost === "127.0.0.1" - ) { - return null; - } - - const suffix = `.${normalizedGateway}`; - if (!normalizedHost.endsWith(suffix)) { - return null; - } - - const tenantLabel = normalizedHost.slice(0, -suffix.length); - const tenantSegments = tenantLabel.split(".").filter(Boolean); - if (tenantSegments.length === 0 || tenantSegments.join(".") !== tenantLabel) { - throw new TenantRuntimeError(`Invalid tenant host: ${hostname}`, 404); - } - - const accountId = `${tenantSegments.join(".")}.${normalizedNamespaceAccountId}`; - if (!NEAR_ACCOUNT_ID_REGEX.test(accountId)) { - throw new TenantRuntimeError(`Invalid tenant account: ${accountId}`, 404); - } - - return accountId; + clearBindingResolverCache(); } function getRemoteConfigCached(bosUrl: string, env: BosEnv) { @@ -347,16 +256,6 @@ async function verifyPluginUiIntegrity( ); } -function isPluginOverrideAllowed( - allowedOverrides: ReadonlyArray, - pluginKey: string, -): boolean { - return ( - isRuntimeOverrideAllowed(allowedOverrides, "plugins") || - isRuntimeOverrideAllowed(allowedOverrides, `plugins.${pluginKey}`) - ); -} - function buildEffectivePluginConfig( basePlugin: RuntimePlugin, tenantPlugin: RuntimePlugin, @@ -372,10 +271,9 @@ function buildEffectiveRuntimeConfig( baseConfig: RuntimeConfig, tenantConfig: RuntimeConfig, tenantAccountId: string, - allowedOverrides: ReadonlyArray, + allowUiOverrides: boolean, + allowBackendOverrides: boolean, ): RuntimeConfig { - warnUnsupportedOverrideTargets(allowedOverrides); - const effectiveConfig: RuntimeConfig = { ...baseConfig, account: tenantAccountId, @@ -385,7 +283,7 @@ function buildEffectiveRuntimeConfig( repository: tenantConfig.repository, }; - if (isRuntimeOverrideAllowed(allowedOverrides, "ui")) { + if (allowUiOverrides) { effectiveConfig.ui = tenantConfig.ui; } @@ -399,7 +297,7 @@ function buildEffectiveRuntimeConfig( continue; } - if (!isPluginOverrideAllowed(allowedOverrides, pluginKey)) { + if (!allowBackendOverrides) { continue; } @@ -412,29 +310,6 @@ function buildEffectiveRuntimeConfig( return effectiveConfig; } -function matchesTenantPattern(accountId: string, pattern: string): boolean { - if (pattern === accountId) return true; - if (pattern.startsWith("*.") && accountId.endsWith(pattern.slice(1))) return true; - return false; -} - -function isSsrAllowed(accountId: string): boolean { - if (parseBoolean(process.env.ALLOW_UNTRUSTED_SSR)) { - return true; - } - - const whitelist = getTenantWhitelist(); - for (const entry of whitelist) { - if (matchesTenantPattern(accountId, entry)) return true; - } - return false; -} - -function getTenantStatus(remoteConfig: Awaited>): string { - const raw = remoteConfig.rawConfig as { status?: string } | undefined; - return raw?.status ?? "active"; -} - export async function resolveRequestRuntime( baseConfig: RuntimeConfig, request: Request, @@ -443,8 +318,9 @@ export async function resolveRequestRuntime( const verificationMode = options?.verification ?? "blocking"; const url = new URL(request.url); const gatewayId = resolveDomain(baseConfig.domain, baseConfig.host.url); - const tenantAccountId = resolveTenantAccountId(url.hostname, gatewayId, baseConfig.account); - if (!tenantAccountId) { + const bindingResolver = options?.bindingResolver ?? createBindingResolver(baseConfig); + const binding = await bindingResolver.resolve(url.hostname); + if (!binding) { return { config: baseConfig, tenantAccountId: null, @@ -453,6 +329,14 @@ export async function resolveRequestRuntime( }; } + const tenantAccountId = binding.accountId; + if (binding.status === "suspended") { + throw new TenantRuntimeError("Tenant is suspended", 503); + } + if (binding.status === "pending_deletion") { + throw new TenantRuntimeError("Tenant has been deleted", 410); + } + const bosUrl = `bos://${tenantAccountId}/${gatewayId}`; const remoteConfig = await getRemoteConfigCached(bosUrl, "production"); const baseBosUrl = `bos://${baseConfig.account}/${gatewayId}`; @@ -483,17 +367,10 @@ export async function resolveRequestRuntime( baseConfig, tenantRuntimeConfig, tenantAccountId, - getAllowedOverrides(), + binding.allowUiOverrides, + binding.allowBackendOverrides, ); - const tenantStatus = getTenantStatus(remoteConfig); - if (tenantStatus === "suspended") { - throw new TenantRuntimeError("Tenant is suspended", 503); - } - if (tenantStatus === "pending_deletion") { - throw new TenantRuntimeError("Tenant has been deleted", 410); - } - if (effectiveConfig.ui.url !== baseConfig.ui.url) { await verifyUiIntegrity(effectiveConfig, verificationMode); } @@ -512,7 +389,7 @@ export async function resolveRequestRuntime( const ssrAllowed = Boolean(effectiveConfig.ui.ssrUrl) && Boolean(effectiveConfig.ui.ssrIntegrity) && - isSsrAllowed(tenantAccountId); + binding.allowSsr; return { config: ssrAllowed diff --git a/host/tests/integration/ssr-bundled-runtime.test.ts b/host/tests/integration/ssr-bundled-runtime.test.ts index d26b3177..c880184a 100644 --- a/host/tests/integration/ssr-bundled-runtime.test.ts +++ b/host/tests/integration/ssr-bundled-runtime.test.ts @@ -57,7 +57,7 @@ describe("bundled host SSR runtime", () => { const html = await response.text(); expect(response.status).toBe(200); - expect(html).toContain("connect to everything"); + expect(html).toContain("everything.dev"); expect(html).not.toContain("SSR unavailable, showing client app."); expect(html).not.toContain("

Loading...

"); diff --git a/host/tests/integration/tenant-host-nested.test.ts b/host/tests/integration/tenant-host-nested.test.ts index e2891320..ccde7073 100644 --- a/host/tests/integration/tenant-host-nested.test.ts +++ b/host/tests/integration/tenant-host-nested.test.ts @@ -11,19 +11,6 @@ vi.mock("everything-dev/config", async () => { await vi.importActual("everything-dev/config"); return { ...actual, - parseRuntimeOverrideTargets: (value?: string | null) => - value - ? [ - ...new Set( - value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ] - : [], - isRuntimeOverrideAllowed: (targets: string[], target: string) => - targets.includes(target) || (target.startsWith("plugins.") && targets.includes("plugins.*")), loadRemoteConfig: loadRemoteConfigMock, buildRuntimeConfig: buildRuntimeConfigMock, }; @@ -39,6 +26,29 @@ vi.mock("everything-dev/integrity", async () => { }; }); +vi.mock("../../src/services/binding-resolver", async () => { + const actual = await vi.importActual( + "../../src/services/binding-resolver", + ); + return { + ...actual, + createBindingResolver: () => ({ + resolve: async (hostname: string) => + hostname === "chicago.alice.linktree.com" + ? { + hostname: "chicago.alice.linktree.com", + accountId: "chicago.alice.linktree.near", + allowUiOverrides: true, + allowBackendOverrides: false, + allowSsr: false, + status: "active", + } + : null, + clear: () => {}, + }), + }; +}); + const { runServer } = await import("../../src/program"); function createBaseConfig() { @@ -117,9 +127,6 @@ describe("tenant host nested integration", () => { const previousNodeEnv = process.env.NODE_ENV; const previousHost = process.env.HOST; const previousPort = process.env.PORT; - const previousAllowOverride = process.env.ALLOW_OVERRIDE; - const previousTenantWhitelist = process.env.TENANT_WHITELIST; - const previousAllowUntrustedSsr = process.env.ALLOW_UNTRUSTED_SSR; beforeAll(async () => { assetServer = await startStaticServer({}); @@ -129,9 +136,6 @@ describe("tenant host nested integration", () => { process.env.NODE_ENV = "production"; process.env.HOST = "127.0.0.1"; process.env.PORT = String(port); - process.env.ALLOW_OVERRIDE = "ui,plugins.*"; - process.env.TENANT_WHITELIST = "chicago.alice.linktree.near"; - process.env.ALLOW_UNTRUSTED_SSR = "false"; process.argv.push("--proxy"); const config = createBaseConfig(); @@ -162,9 +166,6 @@ describe("tenant host nested integration", () => { process.env.NODE_ENV = previousNodeEnv; process.env.HOST = previousHost; process.env.PORT = previousPort; - process.env.ALLOW_OVERRIDE = previousAllowOverride; - process.env.TENANT_WHITELIST = previousTenantWhitelist; - process.env.ALLOW_UNTRUSTED_SSR = previousAllowUntrustedSsr; const proxyIdx = process.argv.indexOf("--proxy"); if (proxyIdx !== -1) { diff --git a/host/tests/integration/tenant-runtime.test.ts b/host/tests/integration/tenant-runtime.test.ts index 9927d32e..1831a817 100644 --- a/host/tests/integration/tenant-runtime.test.ts +++ b/host/tests/integration/tenant-runtime.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeConfig } from "../../src/services/config"; const loadRemoteConfigMock = vi.fn(); @@ -7,19 +7,6 @@ const verifySriForUrlMock = vi.fn(); vi.mock("everything-dev/config", async () => { return { - parseRuntimeOverrideTargets: (value?: string | null) => - value - ? [ - ...new Set( - value - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean), - ), - ] - : [], - isRuntimeOverrideAllowed: (targets: string[], target: string) => - targets.includes(target) || (target.startsWith("plugins.") && targets.includes("plugins.*")), loadRemoteConfig: loadRemoteConfigMock, buildRuntimeConfig: buildRuntimeConfigMock, }; @@ -33,6 +20,8 @@ const { clearTenantRuntimeCaches, resolveRequestRuntime } = await import( "../../src/services/tenant-runtime" ); +import type { BindingResolver } from "../../src/services/binding-resolver"; + function createDeferred() { let resolve!: (value: T | PromiseLike) => void; const promise = new Promise((res) => { @@ -41,6 +30,34 @@ function createDeferred() { return { promise, resolve }; } +function createMockBindingResolver( + ...hostnames: Array<{ + hostname: string; + allowUiOverrides?: boolean; + allowBackendOverrides?: boolean; + allowSsr?: boolean; + status?: "active" | "pending" | "suspended" | "pending_deletion"; + }> +): BindingResolver { + const map = new Map( + hostnames.map((entry) => [ + entry.hostname, + { + hostname: entry.hostname, + accountId: entry.hostname.replace(/\.com$/, ".near"), + allowUiOverrides: entry.allowUiOverrides ?? true, + allowBackendOverrides: entry.allowBackendOverrides ?? false, + allowSsr: entry.allowSsr ?? false, + status: entry.status ?? "active", + }, + ]), + ); + return { + resolve: async (hostname: string) => map.get(hostname) ?? null, + clear: () => {}, + }; +} + function createBaseRuntimeConfig(): RuntimeConfig { return { env: "production", @@ -96,35 +113,25 @@ function createBaseRuntimeConfig(): RuntimeConfig { } describe("resolveRequestRuntime", () => { - const envSnapshot = { ...process.env }; - beforeEach(() => { vi.clearAllMocks(); clearTenantRuntimeCaches(); verifySriForUrlMock.mockResolvedValue(undefined); - process.env = { - ...envSnapshot, - ALLOW_OVERRIDE: "ui", - TENANT_WHITELIST: "alice.linktree.near", - ALLOW_UNTRUSTED_SSR: "false", - }; - }); - - afterEach(() => { - process.env = { ...envSnapshot }; }); it("returns the base runtime on the bare domain", async () => { const baseConfig = createBaseRuntimeConfig(); - const result = await resolveRequestRuntime(baseConfig, new Request("https://linktree.com/")); + const result = await resolveRequestRuntime(baseConfig, new Request("https://linktree.com/"), { + bindingResolver: createMockBindingResolver(), + }); expect(result.config).toBe(baseConfig); expect(result.tenantAccountId).toBeNull(); expect(loadRemoteConfigMock).not.toHaveBeenCalled(); }); - it("derives tenant accounts relative to the active runtime account", async () => { + it("resolves the tenant account for a hostname via the binding resolver", async () => { loadRemoteConfigMock.mockResolvedValue({ source: "bos://alice.linktree.near/linktree.com", rawConfig: { @@ -155,6 +162,12 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( createBaseRuntimeConfig(), new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + }), + }, ); expect(result.tenantAccountId).toBe("alice.linktree.near"); @@ -164,7 +177,7 @@ describe("resolveRequestRuntime", () => { ); }); - it("supports nested tenant labels within the active runtime namespace", async () => { + it("resolves nested tenant hostnames via the binding resolver", async () => { loadRemoteConfigMock.mockResolvedValue({ source: "bos://chicago.alice.linktree.near/linktree.com", rawConfig: { @@ -198,6 +211,12 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( createBaseRuntimeConfig(), new Request("https://chicago.alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "chicago.alice.linktree.com", + allowUiOverrides: true, + }), + }, ); expect(result.tenantAccountId).toBe("chicago.alice.linktree.near"); @@ -230,61 +249,37 @@ describe("resolveRequestRuntime", () => { buildRuntimeConfigMock.mockResolvedValue(createBaseRuntimeConfig()); await expect( - resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/")), + resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ hostname: "alice.linktree.com" }), + }), ).rejects.toThrow("must extend bos://linktree.near/linktree.com"); }); - it("rejects a suspended tenant with 503 based on published config status", async () => { - loadRemoteConfigMock.mockResolvedValue({ - source: "bos://alice.linktree.near/linktree.com", - rawConfig: { - extends: "bos://linktree.near/linktree.com", - status: "suspended", - }, - config: { - account: "alice.linktree.near", - app: { - host: { development: "local:host", production: "https://host.example.com" }, - ui: { name: "ui", production: "https://cdn.example.com/alice-ui" }, - api: { name: "api", production: "https://api.example.com" }, - }, - }, - extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], - }); - - buildRuntimeConfigMock.mockResolvedValue(createBaseRuntimeConfig()); - + it("rejects a suspended tenant with 503 based on the binding status", async () => { await expect( - resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/")), + resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + status: "suspended", + }), + }), ).rejects.toMatchObject({ status: 503, message: "Tenant is suspended" }); + expect(loadRemoteConfigMock).not.toHaveBeenCalled(); }); - it("rejects a pending_deletion tenant with 410 based on published config status", async () => { - loadRemoteConfigMock.mockResolvedValue({ - source: "bos://alice.linktree.near/linktree.com", - rawConfig: { - extends: "bos://linktree.near/linktree.com", - status: "pending_deletion", - }, - config: { - account: "alice.linktree.near", - app: { - host: { development: "local:host", production: "https://host.example.com" }, - ui: { name: "ui", production: "https://cdn.example.com/alice-ui" }, - api: { name: "api", production: "https://api.example.com" }, - }, - }, - extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], - }); - - buildRuntimeConfigMock.mockResolvedValue(createBaseRuntimeConfig()); - + it("rejects a pending_deletion tenant with 410 based on the binding status", async () => { await expect( - resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/")), + resolveRequestRuntime(createBaseRuntimeConfig(), new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + status: "pending_deletion", + }), + }), ).rejects.toMatchObject({ status: 410, message: "Tenant has been deleted" }); + expect(loadRemoteConfigMock).not.toHaveBeenCalled(); }); - it("applies a tenant UI override and allows SSR for whitelisted tenants", async () => { + it("applies a tenant UI override and allows SSR when the binding enables both", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -325,6 +320,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(result.tenantAccountId).toBe("alice.linktree.near"); @@ -338,7 +340,7 @@ describe("resolveRequestRuntime", () => { ); }); - it("disables SSR for non-whitelisted tenants when untrusted SSR is off", async () => { + it("disables SSR when the binding does not allow it", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -373,6 +375,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://bob.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "bob.linktree.com", + allowUiOverrides: true, + allowSsr: false, + }), + }, ); expect(result.ssrAllowed).toBe(false); @@ -380,7 +389,7 @@ describe("resolveRequestRuntime", () => { expect(result.config.ui.ssrIntegrity).toBeUndefined(); }); - it("disables SSR for whitelisted tenants when ssrIntegrity is missing", async () => { + it("disables SSR for SSR-enabled bindings when ssrIntegrity is missing", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -419,6 +428,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(result.ssrAllowed).toBe(false); @@ -426,7 +442,7 @@ describe("resolveRequestRuntime", () => { expect(result.config.ui.ssrIntegrity).toBeUndefined(); }); - it("disables SSR for whitelisted tenants when ssrUrl is missing", async () => { + it("disables SSR for SSR-enabled bindings when ssrUrl is missing", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -460,6 +476,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(result.ssrAllowed).toBe(false); @@ -467,7 +490,7 @@ describe("resolveRequestRuntime", () => { expect(result.config.ui.ssrIntegrity).toBeUndefined(); }); - it("allows SSR for whitelisted tenants when both ssrUrl and ssrIntegrity are present", async () => { + it("allows SSR when the binding enables SSR and both ssrUrl and ssrIntegrity are present", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ @@ -508,6 +531,13 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(result.ssrAllowed).toBe(true); @@ -515,9 +545,8 @@ describe("resolveRequestRuntime", () => { expect(result.config.ui.ssrIntegrity).toBe("sha384-alice-ssr"); }); - it("applies existing plugin UI overrides when plugins are allowed", async () => { + it("applies existing plugin UI overrides when backend overrides are allowed", async () => { const baseConfig = createBaseRuntimeConfig(); - process.env.ALLOW_OVERRIDE = "ui,plugins.*"; loadRemoteConfigMock.mockResolvedValue({ source: "bos://alice.linktree.near/linktree.com", @@ -591,6 +620,14 @@ describe("resolveRequestRuntime", () => { const result = await resolveRequestRuntime( baseConfig, new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + allowBackendOverrides: true, + }), + }, ); expect(result.config.plugins?.apps.ui?.url).toBe("https://plugins.example.com/alice-apps-ui"); @@ -639,7 +676,13 @@ describe("resolveRequestRuntime", () => { }, }); - await resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/")); + await resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }); const refresh = createDeferred(); verifySriForUrlMock.mockImplementationOnce(() => refresh.promise); @@ -648,6 +691,11 @@ describe("resolveRequestRuntime", () => { await expect( resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/asset.js"), { verification: "stale-while-revalidate", + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), }), ).resolves.toMatchObject({ tenantAccountId: "alice.linktree.near" }); expect(verifySriForUrlMock).toHaveBeenCalledTimes(2); @@ -655,6 +703,11 @@ describe("resolveRequestRuntime", () => { await expect( resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/asset-2.js"), { verification: "stale-while-revalidate", + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), }), ).resolves.toMatchObject({ tenantAccountId: "alice.linktree.near" }); expect(verifySriForUrlMock).toHaveBeenCalledTimes(2); @@ -704,7 +757,13 @@ describe("resolveRequestRuntime", () => { }, }); - await resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/")); + await resolveRequestRuntime(baseConfig, new Request("https://alice.linktree.com/"), { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }); const refresh = createDeferred(); verifySriForUrlMock.mockImplementationOnce(() => refresh.promise); @@ -716,6 +775,11 @@ describe("resolveRequestRuntime", () => { new Request("https://alice.linktree.com/"), { verification: "blocking", + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), }, ).then(() => { settled = true; @@ -732,50 +796,109 @@ describe("resolveRequestRuntime", () => { } }); - it("recomputes the tenant whitelist when the env value changes", async () => { + it("gates SSR per tenant based on the binding allowSsr flag", async () => { const baseConfig = createBaseRuntimeConfig(); loadRemoteConfigMock.mockResolvedValue({ - source: "bos://bob.linktree.near/linktree.com", + source: "bos://alice.linktree.near/linktree.com", rawConfig: { extends: "bos://linktree.near/linktree.com", }, config: { - account: "bob.linktree.near", + account: "alice.linktree.near", app: { host: { development: "local:host", production: "https://host.example.com" }, - ui: { name: "ui", production: "https://cdn.example.com/bob-ui" }, + ui: { name: "ui", production: "https://cdn.example.com/alice-ui" }, api: { name: "api", production: "https://api.example.com" }, }, }, - extendsChain: ["bos://bob.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], + extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], }); buildRuntimeConfigMock.mockResolvedValue({ ...baseConfig, - account: "bob.linktree.near", + account: "alice.linktree.near", ui: { ...baseConfig.ui, - url: "https://cdn.example.com/bob-ui", - entry: "https://cdn.example.com/bob-ui/mf-manifest.json", - integrity: "sha384-bob", - ssrUrl: "https://cdn.example.com/bob-ui-ssr", - ssrIntegrity: "sha384-bob-ssr", + url: "https://cdn.example.com/alice-ui", + entry: "https://cdn.example.com/alice-ui/mf-manifest.json", + integrity: "sha384-alice", + ssrUrl: "https://cdn.example.com/alice-ui-ssr", + ssrIntegrity: "sha384-alice-ssr", }, }); const blocked = await resolveRequestRuntime( baseConfig, - new Request("https://bob.linktree.com/"), + new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: false, + }), + }, ); expect(blocked.ssrAllowed).toBe(false); - - process.env.TENANT_WHITELIST = "bob.linktree.near"; + expect(blocked.config.ui.ssrUrl).toBeUndefined(); const allowed = await resolveRequestRuntime( baseConfig, - new Request("https://bob.linktree.com/"), + new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: true, + allowSsr: true, + }), + }, ); expect(allowed.ssrAllowed).toBe(true); + expect(allowed.config.ui.ssrUrl).toBe("https://cdn.example.com/alice-ui-ssr"); + }); + + it("does not apply tenant UI overrides when the binding disallows them", async () => { + const baseConfig = createBaseRuntimeConfig(); + + loadRemoteConfigMock.mockResolvedValue({ + source: "bos://alice.linktree.near/linktree.com", + rawConfig: { + extends: "bos://linktree.near/linktree.com", + }, + config: { + account: "alice.linktree.near", + app: { + host: { development: "local:host", production: "https://host.example.com" }, + ui: { name: "ui", production: "https://cdn.example.com/alice-ui" }, + api: { name: "api", production: "https://api.example.com" }, + }, + }, + extendsChain: ["bos://alice.linktree.near/linktree.com", "bos://linktree.near/linktree.com"], + }); + + buildRuntimeConfigMock.mockResolvedValue({ + ...baseConfig, + account: "alice.linktree.near", + ui: { + ...baseConfig.ui, + url: "https://cdn.example.com/alice-ui", + entry: "https://cdn.example.com/alice-ui/mf-manifest.json", + integrity: "sha384-alice", + }, + }); + + const result = await resolveRequestRuntime( + baseConfig, + new Request("https://alice.linktree.com/"), + { + bindingResolver: createMockBindingResolver({ + hostname: "alice.linktree.com", + allowUiOverrides: false, + }), + }, + ); + + expect(result.config.ui.url).toBe(baseConfig.ui.url); + expect(verifySriForUrlMock).not.toHaveBeenCalled(); }); }); diff --git a/package.json b/package.json index 073c58bc..90b7160f 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "publish": "bun packages/everything-dev/src/cli.ts publish", "bos": "bun packages/everything-dev/src/cli.ts", "types:gen": "bun packages/everything-dev/src/cli.ts types gen", - "typecheck": "bun run types:gen && bun run --cwd packages/everything-dev typecheck & bun run --cwd ui tsc --noEmit & bun run --cwd api tsc --noEmit & bun run --cwd host tsc --noEmit & wait", + "typecheck": "BOS_NO_BANNER=1 bun run types:gen && BOS_NO_BANNER=1 bun run bos typecheck", "lint": "biome check .", "lint:fix": "biome check --write .", "format": "biome format --write .", @@ -98,15 +98,19 @@ "test:api": "cd api && bun run test tests/integration/ tests/unit/", "test:integration": "cd api && bun run test tests/integration/", "test:e2e": "bun run --cwd host test:e2e", - "regression:start:dev": "BOS_NO_PERSIST_PORTS=1 bun packages/everything-dev/src/cli.ts dev --no-interactive --port 4100 --api-port 4101 --auth-port 4102 --ui-port 4103 --plugin-port-start 4110", + "regression:start:dev": "BOS_NO_PERSIST_PORTS=1 bun packages/everything-dev/src/cli.ts dev --no-interactive --ssr --port 4100 --api-port 4101 --auth-port 4102 --ui-port 4103 --plugin-port-start 4110", "regression:start:prod": "BOS_NO_PERSIST_PORTS=1 PORT=4100 bun ./node_modules/everything-dev/dist/cli.mjs start --no-interactive", + "regression:start:backcompat": "BOS_NO_PERSIST_PORTS=1 bun packages/everything-dev/src/cli.ts dev --no-interactive --ssr --host local --ui remote --api remote --auth local --remote-plugins apps,template --port 4100 --api-port 4101 --auth-port 4102 --ui-port 4103 --plugin-port-start 4110", "test:regression:http:dev": "REGRESSION_MODE=dev sh -lc 'cd tests/regression/http && go test ./... -count=1 -v -timeout 10m'", "test:regression:http:prod": "REGRESSION_MODE=prod sh -lc 'cd tests/regression/http && go test ./... -count=1 -v -timeout 10m'", + "test:regression:http:backcompat": "REGRESSION_MODE=backcompat sh -lc 'cd tests/regression/http && go test ./... -count=1 -v -timeout 10m'", "test:regression:browser:install": "bunx playwright install --with-deps chromium", "test:regression:browser:dev": "REGRESSION_MODE=dev bunx playwright test -c tests/regression/browser/playwright.config.mjs --project=dev", "test:regression:browser:prod": "REGRESSION_MODE=prod bunx playwright test -c tests/regression/browser/playwright.config.mjs --project=prod", - "test:regression:dev": "bun run test:regression:http:dev && bun run test:regression:browser:dev", - "test:regression:prod": "bun run test:regression:http:prod && bun run test:regression:browser:prod", + "test:regression:browser:backcompat": "REGRESSION_MODE=backcompat bunx playwright test -c tests/regression/browser/playwright.config.mjs --project=backcompat", + "test:regression:dev": "bun run test:regression:http:dev; bun run test:regression:browser:dev", + "test:regression:prod": "bun run test:regression:http:prod; bun run test:regression:browser:prod", + "test:regression:backcompat": "bun run test:regression:http:backcompat; bun run test:regression:browser:backcompat", "test:regression": "bun run test:regression:dev", "dev:postgres": "docker compose up -d --wait && bun run dev", "dev:postgres:down": "docker compose down", diff --git a/packages/every-plugin/skills/plugin-client/SKILL.md b/packages/every-plugin/skills/plugin-client/SKILL.md index 7c8125b8..5a9f9d30 100644 --- a/packages/every-plugin/skills/plugin-client/SKILL.md +++ b/packages/every-plugin/skills/plugin-client/SKILL.md @@ -437,7 +437,7 @@ This writes `bos.config.json` with `extends`, scaffolds the project, and generat ### What Can Be Overridden -Child configs can override `app.ui` (custom UI CDN) and `plugins.` (custom plugin URLs). The host's `ALLOW_OVERRIDE` env var controls which sections tenants can customize (`ui`, `plugins`, `plugins.`). +Child configs can override `app.ui` (custom UI CDN) and `plugins.` (custom plugin URLs). Which sections a tenant can customize is controlled per-tenant by the `allow_ui_overrides` and `allow_backend_overrides` flags on the tenant record, resolved by the host from the API's `GET /tenants/bindings` endpoint (cached for 30s). See the `extends-config` skill for deep merge semantics, per-environment extends, and canonical field ordering. diff --git a/packages/everything-dev/skills/api-and-auth/SKILL.md b/packages/everything-dev/skills/api-and-auth/SKILL.md index 06416252..e302ff66 100644 --- a/packages/everything-dev/skills/api-and-auth/SKILL.md +++ b/packages/everything-dev/skills/api-and-auth/SKILL.md @@ -356,6 +356,54 @@ export function createPluginsClient(result, context) { } ``` +### Tenant-Scoped Data + +A **tenant** is a deployment record (subdomain + NEAR account + UI/backend/SSR override +permissions) — distinct from an organization (a group of users) and from a user's own data. +If your plugin stores data that belongs to a specific tenant deployment (not just a user or +org), resolve the tenant and scope every query to it. + +The `api` plugin owns the `tenants` table and exposes public lookup routes (no auth required — +tenant lookups are read-only and safe to expose): + +```ts +// From any plugin, via pluginsClient.api (injected through withPlugins()) +const tenant = context.organization?.activeOrganizationId + ? await plugins.api().resolveTenantByOrgId({ orgId: context.organization.activeOrganizationId }) + : await plugins.api().resolveTenant({ accountId: context.near?.primaryAccountId ?? "" }); +``` + +Build a local `requireTenant` middleware in your own plugin (mirrors `requireOrganization`): + +```ts +const requireTenant = builder.middleware(async ({ context, next }) => { + const activeOrgId = context.organization?.activeOrganizationId; + const tenant = activeOrgId + ? await plugins.api().resolveTenantByOrgId({ orgId: activeOrgId }).catch(() => null) + : null; + if (!tenant) { + throw new ORPCError("FORBIDDEN", { message: "No tenant found for this organization" }); + } + return next({ context: { ...context, tenant } }); +}); + +builder.listReports.use(requireTenant).handler(async ({ context }) => { + return await services.reports.listByTenant(context.tenant.id); // always filtered +}); +``` + +**Row-level convention (interim isolation)**: add a `tenantId` column to any table holding +tenant-specific application data, resolved server-side and never trusted from client input — +same discipline the `tenants` table itself uses for `orgId` scoping. See +`plugins/_template/src/db/schema.ts` for a commented example table. + +**Forward path**: the target architecture (see `plans/beta-v2-tenants.md`) is per-tenant-per-plugin +Postgres schema isolation (`tenant__plugin_`, `search_path` injected per request). That +requires request-scoped DB access instead of the current initialize-time singleton pattern — a +larger change, only worth it once there's a real multi-tenant plugin ecosystem to isolate. The +`tenantId` column convention above is forward-compatible: when schema isolation lands, the column +becomes redundant and can be dropped without reworking query logic. + ## Generated Types See `references/generated-types.md` for the full table — files, contents, and regeneration triggers. diff --git a/packages/everything-dev/skills/extends-config/SKILL.md b/packages/everything-dev/skills/extends-config/SKILL.md index 65fe9041..c8fc8d0a 100644 --- a/packages/everything-dev/skills/extends-config/SKILL.md +++ b/packages/everything-dev/skills/extends-config/SKILL.md @@ -73,13 +73,7 @@ Use this mental model: - `domain` is the public ingress for this runtime - a runtime can be a child in lineage and still become a new tenant root on its own domain -With host env like: - -```bash -ALLOW_OVERRIDE=ui,plugins.* -TENANT_WHITELIST=pizza.pingpayio.near -ALLOW_UNTRUSTED_SSR=false -``` +Tenant permissions come from the DB, not env vars. The host fetches the tenant bindings map from the API's `GET /tenants/bindings` endpoint (cached for 30s), and the tenant record's allow flags gate what may be overridden and whether SSR is permitted. Design target for request mapping: - `pizza.com` -> base runtime `bos://pizza.pingpayio.near/pizza.com` @@ -101,9 +95,8 @@ The tenant config must extend the base BOS runtime. Tenant API/auth overrides an ### SSR behavior Tenant SSR is gated separately from inheritance: -- if `ALLOW_UNTRUSTED_SSR=true`, any valid tenant with SSR config may SSR -- otherwise the tenant account must be listed in `TENANT_WHITELIST` -- non-whitelisted tenants fall back to client rendering +- a tenant may SSR only when its record has `allow_ssr` set +- tenants without `allow_ssr` fall back to client rendering ## Resolved Config: `.bos/bos.resolved-config.json` diff --git a/packages/everything-dev/skills/init-upgrade/SKILL.md b/packages/everything-dev/skills/init-upgrade/SKILL.md index df40b85e..dad27afa 100644 --- a/packages/everything-dev/skills/init-upgrade/SKILL.md +++ b/packages/everything-dev/skills/init-upgrade/SKILL.md @@ -81,15 +81,9 @@ Child apps can either run as their own base runtime on their own domain, or as r Shared-host children must extend the base runtime and do not introduce new server-side plugin IDs dynamically. -### 4. Host deployment env for shared-host mode +### 4. Host resolution for shared-host mode -The shared host uses these env vars to resolve descendant requests: - -```bash -ALLOW_OVERRIDE=ui,plugins.* -TENANT_WHITELIST=pizza.pingpayio.near,chicago.pizza.pingpayio.near -ALLOW_UNTRUSTED_SSR=false -``` +The shared host resolves descendant requests via a DB-backed binding map. It fetches tenant permissions from the API's `GET /tenants/bindings` endpoint (cached for 30s) — no env vars are needed: Design target, for example: - `pingpay.io` -> base runtime `bos://pingpayio.near/pingpay.io` diff --git a/packages/everything-dev/skills/super-app/SKILL.md b/packages/everything-dev/skills/super-app/SKILL.md index 4adae2f3..bc32b536 100644 --- a/packages/everything-dev/skills/super-app/SKILL.md +++ b/packages/everything-dev/skills/super-app/SKILL.md @@ -87,20 +87,13 @@ This runtime is still a lineage child because it extends the parent, but it is a You can also override existing plugin UIs and sidebar entries for tenant-specific navigation. -## Host Env For Tenant Mode +## Host Resolution For Tenant Mode -The shared host uses these env vars to resolve tenants: +The shared host resolves tenant permissions from the API, not env vars. It fetches the tenant bindings map from the API's `GET /tenants/bindings` endpoint (cached for 30s) and applies DB-backed per-tenant permissions: -```bash -ALLOW_OVERRIDE=ui,plugins.* -TENANT_WHITELIST=pizza.pingpayio.near,chicago.pizza.pingpayio.near -ALLOW_UNTRUSTED_SSR=false -``` - -Meaning: -- `ALLOW_OVERRIDE` controls which tenant config sections can affect request-scoped composition. Format: comma-separated list (e.g., `ui,plugins.*`) where `plugins.*` is a glob matching all plugin overrides -- `TENANT_WHITELIST` controls which tenants may use SSR -- `ALLOW_UNTRUSTED_SSR=true` allows SSR for any valid tenant with SSR config +- `allow_ui_overrides` controls whether the tenant `ui` override can affect request-scoped composition +- `allow_backend_overrides` controls whether tenant plugin UI overrides (`plugins..ui`, `plugins..sidebar`) are applied +- `allow_ssr` controls whether the tenant may use SSR ## Resolution Rules @@ -141,7 +134,7 @@ If tenant overrides are not applying as expected: 1. **Verify the tenant config exists in FastKV**: The config must be published to `{tenantAccount}/bos/gateways/{gateway}/bos.config.json` 2. **Check extends chain**: The tenant config must extend the base runtime via its `extends` field 3. **Check host logs**: Run `cat .bos/logs/host.log` and look for tenant resolution messages — the host logs which runtime config it resolved for each request -4. **Check env vars**: `ALLOW_OVERRIDE` must include the sections you're overriding (e.g., `ui` or `plugins.*`) +4. **Check tenant permissions**: the tenant record must have the relevant allow flag set in the database (e.g., `allow_ui_overrides` for a `ui` override, `allow_backend_overrides` for plugin UI overrides) 5. **Verify integrity**: If tenant remote URLs have integrity hashes, the host validates them — mismatches cause rejection 6. **Missing tenant config**: If the tenant config is not found in FastKV, the host silently serves the base runtime config without tenant overrides — no error is surfaced to the browser diff --git a/packages/everything-dev/src/api-contract.ts b/packages/everything-dev/src/api-contract.ts index 657d1eb4..4f1993f6 100644 --- a/packages/everything-dev/src/api-contract.ts +++ b/packages/everything-dev/src/api-contract.ts @@ -701,7 +701,7 @@ export async function syncApiContractBridge(opts: { ); pluginResults.forEach((result, index) => { - const [key, plugin] = resolvablePlugins[index]; + const [key, plugin] = resolvablePlugins[index]!; if (result.status === "fulfilled") { sources.push(result.value.source); status.push({ diff --git a/packages/everything-dev/src/cli.ts b/packages/everything-dev/src/cli.ts index c8941bc6..b1bbd372 100644 --- a/packages/everything-dev/src/cli.ts +++ b/packages/everything-dev/src/cli.ts @@ -20,6 +20,7 @@ import type { PsResult, StartOptions, StartResult, + TypecheckWorkspaceResult, } from "./contract"; import type { ProgressEvent, StartSummary } from "./plugin"; import bosPlugin, { consumeDevSession, pluginEvents } from "./plugin"; @@ -913,6 +914,39 @@ async function main() { return; } + if (descriptor.key === "typecheck") { + console.log(); + if (result.status === "error") { + console.error(`[CLI] ${result.error || "Unknown error"}`); + process.exit(1); + } + const failed = result.results.filter((r: TypecheckWorkspaceResult) => !r.passed); + const passed = result.results.filter((r: TypecheckWorkspaceResult) => r.passed); + console.log(colors.cyan(frames.top(52))); + console.log(` ${icons.app} ${gradients.cyber("TYPECHECK")}`); + console.log(colors.cyan(frames.bottom(52))); + console.log(); + for (const r of result.results) { + const icon = r.passed ? colors.green("✓") : colors.error("✗"); + console.log(` ${icon} ${r.workspace}`); + } + if (result.skipped.length > 0) { + console.log(` ${colors.dim(`Skipped: ${result.skipped.join(", ")}`)}`); + } + console.log(); + if (failed.length > 0) { + console.log(` ${colors.error(`${failed.length} failed`)}`); + for (const f of failed) { + if (f.error) console.log(` ${colors.dim(f.error)}`); + } + console.log(); + process.exit(1); + } + console.log(` ${colors.green(`${passed.length} passed`)}`); + console.log(); + return; + } + if (result?.status === "error" && descriptor.key !== "publish" && descriptor.key !== "deploy") { console.error(`[CLI] ${result.error || "Unknown error"}`); process.exit(1); diff --git a/packages/everything-dev/src/cli/sync.ts b/packages/everything-dev/src/cli/sync.ts index 079fd12c..87bf1350 100644 --- a/packages/everything-dev/src/cli/sync.ts +++ b/packages/everything-dev/src/cli/sync.ts @@ -13,6 +13,7 @@ import { syncResolvedSharedDeps } from "../shared-deps"; import { writeGeneratedInfra } from "./infra"; import { buildChildAgentsMd, + buildChildRootScripts, extractSkillsBlock, personalizeConfig, resolveSourceDir, @@ -153,6 +154,7 @@ export function mergePackageJson( filePath: string, local: PackageJson, template: PackageJson, + childScripts?: Record, ): PackageJson { const merged: PackageJson = { ...local, ...template }; @@ -185,11 +187,14 @@ export function mergePackageJson( if ( (local.scripts && typeof local.scripts === "object") || - (template.scripts && typeof template.scripts === "object") + (template.scripts && typeof template.scripts === "object") || + childScripts ) { const mergedScripts = mergeStringMaps( local.scripts as Record | undefined, - template.scripts as Record | undefined, + filePath === "package.json" && childScripts + ? childScripts + : (template.scripts as Record | undefined), ); if (mergedScripts) { merged.scripts = mergedScripts; @@ -264,6 +269,7 @@ function buildSyncedFileContent( projectDir: string, filePath: string, explicitDestPath?: string, + childScripts?: Record, ): string | Uint8Array { const src = join(sourceDir, filePath); const destPath = @@ -292,7 +298,7 @@ function buildSyncedFileContent( if (localContent) { const local = JSON.parse(localContent) as Record; const template = JSON.parse(templateContent) as Record; - const merged = mergePackageJson(destPath, local, template); + const merged = mergePackageJson(destPath, local, template, childScripts); return `${JSON.stringify(merged, null, 2)}\n`; } } @@ -305,6 +311,7 @@ function writeSyncedFile( projectDir: string, filePath: string, explicitDestPath?: string, + childScripts?: Record, ): void { const destPath = explicitDestPath ?? @@ -313,7 +320,10 @@ function writeSyncedFile( : filePath); const dest = join(projectDir, destPath); mkdirSync(dirname(dest), { recursive: true }); - writeFileSync(dest, buildSyncedFileContent(sourceDir, projectDir, filePath, destPath)); + writeFileSync( + dest, + buildSyncedFileContent(sourceDir, projectDir, filePath, destPath, childScripts), + ); } async function getSelectedChildPlugins( @@ -448,6 +458,13 @@ export async function syncTemplate(projectDir: string, options: SyncOptions): Pr const withHost = existsSync(join(projectDir, "host", "package.json")); const withPlugins = childPlugins.length > 0 || hasPluginsWorkspace(projectDir); + const childScripts = buildChildRootScripts({ + ui: withUi, + api: withApi, + host: withHost, + plugins: withPlugins, + }); + const destToSource = new Map(); for (const destPath of FRAMEWORK_OWNED_SYNC_FILES) { if (destPath.startsWith("ui/") && !withUi) continue; @@ -530,7 +547,9 @@ export async function syncTemplate(projectDir: string, options: SyncOptions): Pr for (const [destPath, filePath] of destToSource.entries()) { const localHash = computeLocalHash(projectDir, destPath); - const sourceHash = computeHash(buildSyncedFileContent(sourceDir, projectDir, filePath)); + const sourceHash = computeHash( + buildSyncedFileContent(sourceDir, projectDir, filePath, undefined, childScripts), + ); if (localHash === null) { added.push(destPath); @@ -604,7 +623,7 @@ export async function syncTemplate(projectDir: string, options: SyncOptions): Pr for (const destPath of filesToWrite) { const sourcePath = destToSource.get(destPath) ?? destPath; - writeSyncedFile(sourceDir, projectDir, sourcePath, destPath); + writeSyncedFile(sourceDir, projectDir, sourcePath, destPath, childScripts); } } diff --git a/packages/everything-dev/src/contract.meta.ts b/packages/everything-dev/src/contract.meta.ts index 4fa8d974..71418b4b 100644 --- a/packages/everything-dev/src/contract.meta.ts +++ b/packages/everything-dev/src/contract.meta.ts @@ -162,6 +162,17 @@ export const cliCommandMeta = { }, }, }, + typecheck: { + commandPath: ["typecheck"], + summary: "Run TypeScript type checking across all local workspaces", + interactive: false, + fields: { + packages: { + positional: true, + description: "Comma-separated workspace list (default: all)", + }, + }, + }, dbStudio: { commandPath: ["db", "studio"], summary: "Open Drizzle Studio for a plugin's database", diff --git a/packages/everything-dev/src/contract.ts b/packages/everything-dev/src/contract.ts index a4625810..dfcab759 100644 --- a/packages/everything-dev/src/contract.ts +++ b/packages/everything-dev/src/contract.ts @@ -184,7 +184,7 @@ function parseNearAmount(value: string): number { const cleaned = value.replace(/[\s_,]/g, ""); const match = cleaned.match(/^(\d+(?:\.\d+)?)near$/i); if (!match) return NaN; - return Number.parseFloat(match[1]); + return Number.parseFloat(match[1]!); } const MIN_PUBLISH_ALLOWANCE_NEAR = 0.3; @@ -422,6 +422,25 @@ export const KillResultSchema = z.object({ error: z.string().optional(), }); +export const TypecheckOptionsSchema = z.object({ + packages: z.string().default("all"), +}); + +export const TypecheckWorkspaceResultSchema = z.object({ + workspace: z.string(), + passed: z.boolean(), + output: z.string().optional(), + error: z.string().optional(), +}); + +export const TypecheckResultSchema = z.object({ + status: z.enum(["success", "error"]), + checked: z.array(z.string()), + skipped: z.array(z.string()), + results: z.array(TypecheckWorkspaceResultSchema), + error: z.string().optional(), +}); + export const bosContract = oc.router({ dev: oc.route({ method: "POST", path: "/dev" }).input(DevOptionsSchema).output(DevResultSchema), start: oc @@ -495,6 +514,10 @@ export const bosContract = oc.router({ .route({ method: "POST", path: "/kill" }) .input(KillOptionsSchema) .output(KillResultSchema), + typecheck: oc + .route({ method: "POST", path: "/typecheck" }) + .input(TypecheckOptionsSchema) + .output(TypecheckResultSchema), }); export type DevOptions = z.infer; @@ -540,3 +563,6 @@ export type PidEntry = z.infer; export type PsResult = z.infer; export type KillOptions = z.infer; export type KillResult = z.infer; +export type TypecheckOptions = z.infer; +export type TypecheckResult = z.infer; +export type TypecheckWorkspaceResult = z.infer; diff --git a/packages/everything-dev/src/fastkv.ts b/packages/everything-dev/src/fastkv.ts index b8c4c9ce..c3abdf87 100644 --- a/packages/everything-dev/src/fastkv.ts +++ b/packages/everything-dev/src/fastkv.ts @@ -73,7 +73,7 @@ export function parseBosUrl(bosUrl: string): { gatewayId: string; pathSegments: string[]; } { - const strippedUrl = bosUrl.split("#")[0]; + const strippedUrl = bosUrl.split("#")[0]!; const match = strippedUrl.match(/^bos:\/\/([^/]+)\/(.+)$/); if (!match?.[1] || !match[2]) { throw new Error(`Invalid BOS URL: ${bosUrl}`); diff --git a/packages/everything-dev/src/infra/planner.ts b/packages/everything-dev/src/infra/planner.ts index 2efb3939..09bd29ee 100644 --- a/packages/everything-dev/src/infra/planner.ts +++ b/packages/everything-dev/src/infra/planner.ts @@ -453,6 +453,10 @@ export function planInfra(input: InfraInput): Effect.Effect { + try { + const configPath = findConfigPath(); + if (!configPath) { + return { + status: "error" as const, + checked: [], + skipped: [], + results: [], + error: "No bos.config.json found in current directory", + }; + } + + const projectDir = resolve(dirname(configPath)); + const refreshed = await loadResolvedConfig({ cwd: projectDir }); + if (!refreshed) { + return { + status: "error" as const, + checked: [], + skipped: [], + results: [], + error: "Failed to load bos.config.json", + }; + } + + const runtime = refreshed.runtime; + type AppTarget = { source?: string; localPath?: string }; + const workspaceEntries: Array<{ key: string; label: string; dir: string }> = []; + const skipped: Array<{ key: string; label: string }> = []; + + const appTargets: Array<{ key: string; label: string; target: AppTarget | undefined }> = [ + { key: "host", label: "host", target: runtime.host }, + { key: "ui", label: "ui", target: runtime.ui }, + { key: "api", label: "api", target: runtime.api }, + ]; + if (runtime.auth) { + appTargets.push({ key: "auth", label: "auth", target: runtime.auth }); + } + + for (const entry of appTargets) { + if ( + entry.target?.source === "local" && + entry.target.localPath && + existsSync(join(entry.target.localPath, "tsconfig.json")) + ) { + workspaceEntries.push({ + key: entry.key, + label: entry.label, + dir: entry.target.localPath, + }); + } else { + skipped.push({ key: entry.key, label: entry.label }); + } + } + + for (const [key, plugin] of Object.entries(runtime.plugins ?? {})) { + const label = `plugins/${key}`; + if ( + plugin.source === "local" && + plugin.localPath && + existsSync(join(plugin.localPath, "tsconfig.json")) + ) { + workspaceEntries.push({ key, label, dir: plugin.localPath }); + } else { + skipped.push({ key, label }); + } + } + + const selected = selectWorkspaceTargets(input.packages, refreshed.config); + const targets = + input.packages === "all" + ? workspaceEntries + : workspaceEntries.filter((entry) => selected.includes(entry.key)); + const skippedEntries = + input.packages === "all" + ? skipped + : skipped.filter((entry) => selected.includes(entry.key)); + + const checked: string[] = []; + const results: Array<{ workspace: string; passed: boolean; error?: string }> = []; + + for (const entry of targets) { + const packageJsonPath = join(entry.dir, "package.json"); + let args: string[] = ["run", "tsc", "--noEmit"]; + if (existsSync(packageJsonPath)) { + try { + const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as { + scripts?: Record; + }; + if (pkg.scripts?.typecheck) { + args = ["run", "typecheck"]; + } + } catch { + // ignore unreadable package.json + } + } + + console.log(`\n ${colors.dim("Checking")} ${colors.cyan(entry.label)}`); + const child = spawnSync("bun", args, { + cwd: entry.dir, + stdio: "inherit", + }); + const passed = child.status === 0; + checked.push(entry.label); + results.push({ + workspace: entry.label, + passed, + error: passed ? undefined : `typecheck failed (exit code ${child.status ?? "n/a"})`, + }); + } + + return { + status: "success" as const, + checked, + skipped: skippedEntries.map((entry) => entry.label), + results, + }; + } catch (error) { + return { + status: "error" as const, + checked: [], + skipped: [], + results: [], + error: error instanceof Error ? error.message : "Unknown error", + }; + } + }), + dbStudio: builder.dbStudio.handler(async ({ input }) => { try { const configPath = findConfigPath(); diff --git a/packages/everything-dev/src/ui/router.ts b/packages/everything-dev/src/ui/router.ts index b999f7b4..31ab29a6 100644 --- a/packages/everything-dev/src/ui/router.ts +++ b/packages/everything-dev/src/ui/router.ts @@ -38,45 +38,26 @@ export async function collectHeadData(router: AnyRouter): Promise { const scriptMap = new Map(); for (const match of router.state.matches) { - const route = - ( - router as AnyRouter & { - routesById?: Record unknown } }>; - } - ).routesById?.[(match as { routeId: string }).routeId] ?? - (match as { route?: { options?: { head?: (...args: unknown[]) => unknown } } }).route; - const headFn = route?.options?.head; - if (!headFn) continue; + const matchData = match as { + meta?: HeadMeta[]; + links?: HeadLink[]; + headScripts?: HeadScript[]; + }; - try { - const headResult = (await headFn({ - loaderData: match.loaderData, - matches: router.state.matches, - match, - params: match.params, - })) as { - meta?: HeadMeta[]; - links?: HeadLink[]; - scripts?: HeadScript[]; - }; - - if (headResult?.meta) { - for (const meta of headResult.meta) { - metaMap.set(getMetaKey(meta), meta); - } + if (matchData.meta) { + for (const meta of matchData.meta) { + metaMap.set(getMetaKey(meta), meta); } - if (headResult?.links) { - for (const link of headResult.links) { - linkMap.set(getLinkKey(link), link); - } + } + if (matchData.links) { + for (const link of matchData.links) { + linkMap.set(getLinkKey(link), link); } - if (headResult?.scripts) { - for (const script of headResult.scripts) { - scriptMap.set(getScriptKey(script), script); - } + } + if (matchData.headScripts) { + for (const script of matchData.headScripts) { + scriptMap.set(getScriptKey(script), script); } - } catch (error) { - console.warn(`[collectHeadData] head() failed for ${match.routeId}:`, error); } } diff --git a/packages/everything-dev/tests/integration/init.structure.test.ts b/packages/everything-dev/tests/integration/init.structure.test.ts index 6ce39a99..0b7d088c 100644 --- a/packages/everything-dev/tests/integration/init.structure.test.ts +++ b/packages/everything-dev/tests/integration/init.structure.test.ts @@ -111,8 +111,8 @@ describe("bos init — structure", () => { expect(existsSync(join(noPluginsDir, "plugins"))).toBe(false); - expect(existsSync(join(noPluginsDir, "ui/src/routes/_layout/apps"))).toBe(false); - expect(existsSync(join(noPluginsDir, "ui/src/routes/_layout/index.tsx"))).toBe(true); + expect(existsSync(join(noPluginsDir, "ui/src/routes/_layout/_public/apps"))).toBe(false); + expect(existsSync(join(noPluginsDir, "ui/src/routes/_layout/_public/index.tsx"))).toBe(true); const config = JSON.parse(readFileSync(join(noPluginsDir, "bos.config.json"), "utf-8")); expect(config.plugins).toEqual({}); diff --git a/packages/everything-dev/tests/integration/personalize-config.test.ts b/packages/everything-dev/tests/integration/personalize-config.test.ts index 5305c3ac..4d00b804 100644 --- a/packages/everything-dev/tests/integration/personalize-config.test.ts +++ b/packages/everything-dev/tests/integration/personalize-config.test.ts @@ -112,9 +112,9 @@ describe("personalizeConfig with real root config", () => { expect(existsSync(join(testDir, "plugins", "apps"))).toBe(true); expect(existsSync(join(testDir, "plugins", "example"))).toBe(false); expect(existsSync(join(testDir, "plugins", "example"))).toBe(false); - expect(existsSync(join(testDir, "ui", "src", "routes", "_layout", "apps", "index.tsx"))).toBe( - true, - ); + expect( + existsSync(join(testDir, "ui", "src", "routes", "_layout", "_public", "apps", "index.tsx")), + ).toBe(true); expect( existsSync(join(testDir, "ui", "src", "routes", "_layout", "_authenticated", "example.tsx")), ).toBe(false); diff --git a/packages/everything-dev/tests/integration/plugin-ui-runtime.test.ts b/packages/everything-dev/tests/integration/plugin-ui-runtime.test.ts index 4bf75cf6..7329358d 100644 --- a/packages/everything-dev/tests/integration/plugin-ui-runtime.test.ts +++ b/packages/everything-dev/tests/integration/plugin-ui-runtime.test.ts @@ -109,7 +109,7 @@ describe("plugin UI runtime config", () => { apps: { name: "isolated-apps", production: "https://isolated-apps.test.dev", - routes: ["ui/src/routes/_layout/apps/**"], + routes: ["ui/src/routes/_layout/_public/apps/**"], }, }, }); @@ -130,7 +130,7 @@ describe("plugin UI runtime config", () => { expect(pluginRuntime?.apps.name).toBe("isolated-apps"); expect(pluginRuntime?.apps.url).toBe("https://isolated-apps.test.dev"); - expect(pluginRuntime?.apps.routes).toEqual(["ui/src/routes/_layout/apps/**"]); + expect(pluginRuntime?.apps.routes).toEqual(["ui/src/routes/_layout/_public/apps/**"]); }); it("does not resolve remote local targets against the consumer root", async () => { diff --git a/packages/everything-dev/tests/integration/sync.structure.test.ts b/packages/everything-dev/tests/integration/sync.structure.test.ts index f2361117..e6aebe85 100644 --- a/packages/everything-dev/tests/integration/sync.structure.test.ts +++ b/packages/everything-dev/tests/integration/sync.structure.test.ts @@ -22,7 +22,7 @@ describe("bos sync — framework-owned files", () => { expect(isFrameworkOwnedSyncFile("Dockerfile")).toBe(false); expect(isFrameworkOwnedSyncFile(".github/renovate.json")).toBe(false); expect(isFrameworkOwnedSyncFile(".opencode/skills/everything-dev/SKILL.md")).toBe(false); - expect(isFrameworkOwnedSyncFile("ui/src/routes/_layout/index.tsx")).toBe(false); + expect(isFrameworkOwnedSyncFile("ui/src/routes/_layout/_public/index.tsx")).toBe(false); expect(isFrameworkOwnedSyncFile("ui/src/components/user-nav.tsx")).toBe(false); expect(isFrameworkOwnedSyncFile("api/src/index.ts")).toBe(false); expect(isFrameworkOwnedSyncFile("plugins/apps/src/index.ts")).toBe(false); @@ -89,6 +89,61 @@ describe("bos sync — package.json merge", () => { expect(merged.workspaces.catalog["everything-dev"]).toBe("^1.2.3"); }); + it("filters root package scripts to the child script set when provided", () => { + const childScripts = { + dev: "bos dev", + build: "bos build", + typecheck: "bun run types:gen && bun run --cwd api typecheck", + }; + const merged = mergePackageJson( + "package.json", + { + name: "my-app", + private: true, + scripts: { + dev: "custom dev", + custom: "custom script", + }, + }, + { + name: "monorepo", + scripts: { + dev: "node_modules/.bin/bos dev", + build: "node_modules/.bin/bos build", + "regression:start:backcompat": "bos dev --ui remote --api remote", + "test:regression:backcompat": "bun run test:regression:http:backcompat", + }, + }, + childScripts, + ) as { + name: string; + scripts: Record; + }; + + expect(merged.name).toBe("my-app"); + expect(merged.scripts.dev).toBe("bos dev"); + expect(merged.scripts.build).toBe("bos build"); + expect(merged.scripts.typecheck).toBe("bun run types:gen && bun run --cwd api typecheck"); + expect(merged.scripts.custom).toBe("custom script"); + expect(merged.scripts["regression:start:backcompat"]).toBeUndefined(); + expect(merged.scripts["test:regression:backcompat"]).toBeUndefined(); + }); + + it("ignores childScripts for non-root package.json", () => { + const childScripts = { dev: "bos dev" }; + const merged = mergePackageJson( + "ui/package.json", + { name: "ui", scripts: { build: "custom build" } }, + { name: "ui", scripts: { build: "bun run build:client" } }, + childScripts, + ) as { + scripts: Record; + }; + + expect(merged.scripts.build).toBe("bun run build:client"); + expect(merged.scripts.dev).toBeUndefined(); + }); + it("preserves custom workspace package data while overwriting scaffold entries", () => { const merged = mergePackageJson( "ui/package.json", diff --git a/packages/everything-dev/tests/unit/upgrade-migration.test.ts b/packages/everything-dev/tests/unit/upgrade-migration.test.ts index 5ae261d7..3889efcc 100644 --- a/packages/everything-dev/tests/unit/upgrade-migration.test.ts +++ b/packages/everything-dev/tests/unit/upgrade-migration.test.ts @@ -229,7 +229,7 @@ describe("upgrade bos config migration", () => { `${JSON.stringify( { domain: "apps.everything.dev", - routes: ["ui/src/routes/_layout/apps/**"], + routes: ["ui/src/routes/_layout/_public/apps/**"], }, null, 2, @@ -249,7 +249,7 @@ describe("upgrade bos config migration", () => { }; }; - expect(rootConfig.plugins.apps.routes).toEqual(["ui/src/routes/_layout/apps/**"]); + expect(rootConfig.plugins.apps.routes).toEqual(["ui/src/routes/_layout/_public/apps/**"]); }); it("deletes plugin bos.config.json files even when no metadata to merge", async () => { diff --git a/plugins/_template/src/db/schema.ts b/plugins/_template/src/db/schema.ts index b355bb35..51574d0f 100644 --- a/plugins/_template/src/db/schema.ts +++ b/plugins/_template/src/db/schema.ts @@ -14,3 +14,21 @@ export const things = pgTable( }, (table) => [index("things_type_idx").on(table.type)], ); + +// Tenant-scoped table convention (interim, row-level isolation): +// +// If your plugin stores data that belongs to a specific tenant deployment +// (not just a user or org), add a `tenantId` column and filter every query +// by it. Resolve the tenant via the API plugin's public lookup routes +// (`pluginsClient.api.resolveTenantByOrgId` / `resolveTenant`) and never +// trust a tenantId passed directly from client input. +// +// export const reports = pgTable("reports", { +// id: uuid("id").defaultRandom().primaryKey(), +// tenantId: text("tenant_id").notNull(), // resolved server-side, always filtered +// title: text("title").notNull(), +// createdAt: timestamp("created_at", { mode: "date", withTimezone: true }).defaultNow().notNull(), +// }, (table) => [index("reports_tenant_id_idx").on(table.tenantId)]); +// +// See the api-and-auth skill's "Tenant-Scoped Data" section for the full +// middleware pattern and the forward path to per-tenant schema isolation. diff --git a/plugins/apps/tsconfig.json b/plugins/apps/tsconfig.json index 94de5854..362a22c2 100644 --- a/plugins/apps/tsconfig.json +++ b/plugins/apps/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { - "lib": ["ESNext"], + "types": ["node"], + "lib": ["ESNext", "DOM"], "target": "ESNext", "module": "ESNext", "moduleDetection": "force", diff --git a/tests/regression/browser/helpers/page-ready.ts b/tests/regression/browser/helpers/page-ready.ts index f508dec3..dab7f790 100644 --- a/tests/regression/browser/helpers/page-ready.ts +++ b/tests/regression/browser/helpers/page-ready.ts @@ -33,6 +33,24 @@ export async function waitForApp(page: Page): Promise { return typeof window.__RUNTIME_CONFIG__ !== "undefined"; }); expect(hasRuntimeConfig).toBeTruthy(); + + try { + await page.waitForFunction( + () => { + const p = (window as { __EVERYTHING_DEV_HYDRATE_PROMISE__?: Promise }) + .__EVERYTHING_DEV_HYDRATE_PROMISE__; + return p !== undefined; + }, + { timeout: 5000 }, + ); + await page.evaluate(async () => { + const p = (window as { __EVERYTHING_DEV_HYDRATE_PROMISE__?: Promise }) + .__EVERYTHING_DEV_HYDRATE_PROMISE__; + if (p) await p; + }); + } catch { + // Hydration promise never surfaced (e.g. client-shell fallback path) — proceed. + } } export function expectNoHydrationFailure(errors: PageErrors) { diff --git a/tests/regression/browser/helpers/seeded.ts b/tests/regression/browser/helpers/seeded.ts index 26c1efce..c580e4f9 100644 --- a/tests/regression/browser/helpers/seeded.ts +++ b/tests/regression/browser/helpers/seeded.ts @@ -42,8 +42,8 @@ export function loadSeedData(): SeedData { } export async function verifyAuthenticated(page: Page) { - await page.goto("/home", { waitUntil: "domcontentloaded" }); + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); await page.waitForTimeout(500); await page.waitForLoadState("networkidle"); - await expect(page.locator("button[title='menu']")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("button[title='account menu']")).toBeVisible({ timeout: 10000 }); } diff --git a/tests/regression/browser/playwright.config.mjs b/tests/regression/browser/playwright.config.mjs index e10c8d22..c8659e01 100644 --- a/tests/regression/browser/playwright.config.mjs +++ b/tests/regression/browser/playwright.config.mjs @@ -1,7 +1,12 @@ import { defineConfig } from "@playwright/test"; const mode = process.env.REGRESSION_MODE ?? "dev"; -const command = mode === "prod" ? "bun run regression:start:prod" : "bun run regression:start:dev"; +const command = + mode === "prod" + ? "bun run regression:start:prod" + : mode === "backcompat" + ? "bun run regression:start:backcompat" + : "bun run regression:start:dev"; export default defineConfig({ testDir: "./specs", @@ -32,5 +37,5 @@ export default defineConfig({ CI: "true", }, }, - projects: [{ name: "dev" }, { name: "prod" }], + projects: [{ name: "dev" }, { name: "prod" }, { name: "backcompat" }], }); diff --git a/tests/regression/browser/specs/auth-client.spec.ts b/tests/regression/browser/specs/auth-client.spec.ts index bda4857d..970989f7 100644 --- a/tests/regression/browser/specs/auth-client.spec.ts +++ b/tests/regression/browser/specs/auth-client.spec.ts @@ -58,9 +58,9 @@ test.describe("authClient", () => { await anonymousBtn.click(); await signInDone; - await page.waitForURL(/\/home$/, { timeout: 15000 }); + await page.waitForURL(/\/dashboard$/, { timeout: 15000 }); await page.reload(); - await page.waitForURL(/\/home$/, { timeout: 15000 }); + await page.waitForURL(/\/dashboard$/, { timeout: 15000 }); await page.waitForLoadState("networkidle"); await waitForApp(page); diff --git a/tests/regression/browser/specs/auth-redirect.spec.ts b/tests/regression/browser/specs/auth-redirect.spec.ts index 94a79c21..afbaf19a 100644 --- a/tests/regression/browser/specs/auth-redirect.spec.ts +++ b/tests/regression/browser/specs/auth-redirect.spec.ts @@ -26,8 +26,8 @@ test.describe("Auth redirect", () => { expectNoHydrationFailure(pageErrors); }); - test("unauthenticated / redirects to /login", async ({ page }) => { - await page.goto("/", { waitUntil: "domcontentloaded" }); + test("unauthenticated /dashboard redirects to /login", async ({ page }) => { + await page.goto("/dashboard", { waitUntil: "domcontentloaded" }); await waitForApp(page); await page.waitForURL(/\/login/, { timeout: 15000 }); diff --git a/tests/regression/browser/specs/logout.spec.ts b/tests/regression/browser/specs/logout.spec.ts index c207bb95..97642bcd 100644 --- a/tests/regression/browser/specs/logout.spec.ts +++ b/tests/regression/browser/specs/logout.spec.ts @@ -8,7 +8,7 @@ test.describe("logout", () => { pageErrors = collectErrors(page); }); - test("sign out redirects to login and session is cleared", async ({ page }) => { + test("sign out lands on public page and session is cleared", async ({ page }) => { await page.goto("/login", { waitUntil: "domcontentloaded" }); await waitForApp(page); @@ -23,23 +23,25 @@ test.describe("logout", () => { await anonymousBtn.click(); await signInDone; - await page.waitForURL(/\/home$/, { timeout: 15000 }); + await page.waitForURL(/\/dashboard$/, { timeout: 15000 }); await page.reload(); - await page.waitForURL(/\/home$/, { timeout: 15000 }); + await page.waitForURL(/\/dashboard$/, { timeout: 15000 }); await page.waitForLoadState("networkidle"); await waitForApp(page); - await page.locator("button[title='menu']").click(); + await page.locator("button[title='account menu']").click(); const signOutItem = page.getByRole("menuitem", { name: "sign out" }); await expect(signOutItem).toBeVisible({ timeout: 5000 }); await signOutItem.click(); - await page.waitForURL(/\/login/, { timeout: 15000 }); - await expect(page.getByText("continue anonymously")).toBeVisible({ timeout: 10000 }); + await page.waitForURL(/\/$/, { timeout: 15000 }); + await expect(page.getByText("Get started")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("button[title='account menu']")).toHaveCount(0); await page.reload({ waitUntil: "domcontentloaded" }); await waitForApp(page); - await expect(page.getByText("continue anonymously")).toBeVisible({ timeout: 10000 }); + await expect(page.getByText("Get started")).toBeVisible({ timeout: 10000 }); + await expect(page.locator("button[title='account menu']")).toHaveCount(0); expectNoHydrationFailure(pageErrors); }); diff --git a/tests/regression/browser/specs/theme.spec.ts b/tests/regression/browser/specs/theme.spec.ts index c8bd569a..0b9a3db8 100644 --- a/tests/regression/browser/specs/theme.spec.ts +++ b/tests/regression/browser/specs/theme.spec.ts @@ -49,19 +49,8 @@ test.describe("theme", () => { await expect(html).toHaveClass(/dark/); - const darkToggle = page.locator("button[aria-label='Switch to light theme']").first(); - await expect(darkToggle).toBeVisible({ timeout: 10000 }); - await darkToggle.click(); - - await expect(html).not.toHaveClass(/dark/); - - const storedAfterLight = await page.evaluate(() => localStorage.getItem("theme")); - expect(storedAfterLight).toBe("light"); - - await page.reload({ waitUntil: "domcontentloaded" }); - await waitForApp(page); - - await expect(html).not.toHaveClass(/dark/); + const storedAfterReload = await page.evaluate(() => localStorage.getItem("theme")); + expect(storedAfterReload).toBe("dark"); expectNoHydrationFailure(pageErrors); }); diff --git a/tests/regression/http/internal/regtest/target.go b/tests/regression/http/internal/regtest/target.go index 98b8cbee..ec4da0ad 100644 --- a/tests/regression/http/internal/regtest/target.go +++ b/tests/regression/http/internal/regtest/target.go @@ -5,14 +5,17 @@ import "os" type TargetMode string const ( - ModeDev TargetMode = "dev" - ModeProd TargetMode = "prod" + ModeDev TargetMode = "dev" + ModeProd TargetMode = "prod" + ModeBackcompat TargetMode = "backcompat" ) func Mode() TargetMode { - m := os.Getenv("REGRESSION_MODE") - if m == "prod" { + switch os.Getenv("REGRESSION_MODE") { + case "prod": return ModeProd + case "backcompat": + return ModeBackcompat } return ModeDev } @@ -22,8 +25,11 @@ func BaseURL() string { } func StartCommand() string { - if Mode() == ModeProd { + switch Mode() { + case ModeProd: return "bun run regression:start:prod" + case ModeBackcompat: + return "bun run regression:start:backcompat" } return "bun run regression:start:dev" } diff --git a/tests/regression/http/metadata_test.go b/tests/regression/http/metadata_test.go index 856cea48..7382a275 100644 --- a/tests/regression/http/metadata_test.go +++ b/tests/regression/http/metadata_test.go @@ -1,6 +1,7 @@ package regression import ( + "strings" "testing" "everything.dev/regression/http/internal/regtest" @@ -33,3 +34,30 @@ func TestRouteMetadata(t *testing.T) { t.Fatal("route HTML missing tag") } } + +func TestProfileMetadata(t *testing.T) { + const accountID = "root.near" + + client := regtest.NewCookieClient() + status, _, body := regtest.GetRaw(t, client, baseURL+"/"+accountID) + regtest.MustStatus(t, status, 200, body) + + if !regtest.HTMLContainsTitle(body) { + t.Fatal("profile HTML missing <title> tag") + } + if !strings.Contains(body, accountID) { + t.Fatalf("profile HTML missing account id %q", accountID) + } + + for _, property := range []string{"og:title", "og:description", "og:type"} { + if !regtest.HTMLContainsMetaProperty(body, property) { + t.Fatalf("profile HTML missing %s meta property", property) + } + } + + for _, name := range []string{"twitter:card", "twitter:title", "twitter:description"} { + if !regtest.HTMLContainsMetaName(body, name) { + t.Fatalf("profile HTML missing %s meta name", name) + } + } +} diff --git a/tests/regression/http/tenant_bindings_test.go b/tests/regression/http/tenant_bindings_test.go new file mode 100644 index 00000000..7ca4c801 --- /dev/null +++ b/tests/regression/http/tenant_bindings_test.go @@ -0,0 +1,184 @@ +package regression + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "everything.dev/regression/http/internal/regtest" +) + +func TestTenantBindingsPublicEndpoint(t *testing.T) { + client := regtest.NewCookieClient() + + t.Run("bindings_return_200_without_auth", func(t *testing.T) { + status, _, body := regtest.GetRaw(t, client, baseURL+"/api/tenants/bindings") + regtest.MustStatus(t, status, 200, body) + }) + + var bindings []struct { + Hostname string `json:"hostname"` + AccountID string `json:"accountId"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + Status string `json:"status"` + } + t.Run("bindings_decode_as_array", func(t *testing.T) { + status, _, body := regtest.GetRaw(t, client, baseURL+"/api/tenants/bindings") + regtest.MustStatus(t, status, 200, body) + if err := json.Unmarshal([]byte(body), &bindings); err != nil { + t.Fatalf("decoding bindings response: %v\nBody: %s", err, body) + } + }) + + t.Run("seeded_tenant_binding_present", func(t *testing.T) { + var seeded *struct { + Hostname string `json:"hostname"` + AccountID string `json:"accountId"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + Status string `json:"status"` + } + for i := range bindings { + if strings.HasPrefix(bindings[i].Hostname, "regression-tenant-") { + seeded = &bindings[i] + break + } + } + if seeded == nil { + t.Fatal("expected seeded tenant binding to be present") + } + if seeded.Status != "active" { + t.Fatalf("expected seeded tenant status 'active', got %q", seeded.Status) + } + if seeded.AccountID != fmt.Sprintf("%s.testnet", seeded.Hostname) { + t.Fatalf("expected account id derived from hostname, got %q", seeded.AccountID) + } + + // The seeded tenant uses the default permission columns. + if !seeded.AllowUiOverrides { + t.Fatal("expected allowUiOverrides to default to true") + } + if seeded.AllowBackendOverrides { + t.Fatal("expected allowBackendOverrides to default to false") + } + if seeded.AllowSsr { + t.Fatal("expected allowSsr to default to false") + } + }) +} + +func TestTenantBindingsReflectAllowFlags(t *testing.T) { + client := regtest.NewCookieClient() + + t.Run("sign_in", func(t *testing.T) { + status, _, body := regtest.PostEmpty(t, client, baseURL+"/api/auth/sign-in/anonymous") + regtest.MustStatus(t, status, 200, body) + }) + + orgName := fmt.Sprintf("regression-flags-org-%d", os.Getpid()) + t.Run("create_org", func(t *testing.T) { + status, _, body := regtest.PostJSON(t, client, baseURL+"/api/auth/organization/create", map[string]string{ + "name": orgName, + "slug": orgName, + }, map[string]string{ + "Origin": "http://localhost:4100", + }) + regtest.MustStatus(t, status, 200, body) + var result struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(body), &result); err != nil { + t.Fatalf("decoding org response: %v\nBody: %s", err, body) + } + if result.ID == "" { + t.Fatal("expected non-empty org id") + } + + status, _, body = regtest.PostJSON(t, client, baseURL+"/api/auth/organization/set-active", map[string]string{ + "organizationId": result.ID, + }, map[string]string{ + "Origin": "http://localhost:4100", + }) + regtest.MustStatus(t, status, 200, body) + }) + + subdomain := fmt.Sprintf("regression-flags-%d", os.Getpid()) + t.Run("create_tenant_with_explicit_flags", func(t *testing.T) { + status, _, body := regtest.PostJSON(t, client, baseURL+"/api/tenants", map[string]any{ + "subdomain": subdomain, + "name": "Flags Tenant", + "accountId": fmt.Sprintf("%s.testnet", subdomain), + "allowUiOverrides": false, + "allowBackendOverrides": true, + "allowSsr": true, + }, nil) + regtest.MustStatus(t, status, 200, body) + + var result struct { + ID string `json:"id"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + } + if err := json.Unmarshal([]byte(body), &result); err != nil { + t.Fatalf("decoding tenant response: %v\nBody: %s", err, body) + } + if result.ID == "" { + t.Fatal("expected non-empty tenant id") + } + if result.AllowUiOverrides { + t.Fatal("expected allowUiOverrides to be false") + } + if !result.AllowBackendOverrides { + t.Fatal("expected allowBackendOverrides to be true") + } + if !result.AllowSsr { + t.Fatal("expected allowSsr to be true") + } + }) + + t.Run("bindings_reflect_flags", func(t *testing.T) { + status, _, body := regtest.GetRaw(t, client, baseURL+"/api/tenants/bindings") + regtest.MustStatus(t, status, 200, body) + + var bindings []struct { + Hostname string `json:"hostname"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + } + if err := json.Unmarshal([]byte(body), &bindings); err != nil { + t.Fatalf("decoding bindings response: %v\nBody: %s", err, body) + } + + var found *struct { + Hostname string `json:"hostname"` + AllowUiOverrides bool `json:"allowUiOverrides"` + AllowBackendOverrides bool `json:"allowBackendOverrides"` + AllowSsr bool `json:"allowSsr"` + } + for i := range bindings { + if bindings[i].Hostname == subdomain { + found = &bindings[i] + break + } + } + if found == nil { + t.Fatal("expected created tenant to appear in bindings") + } + if found.AllowUiOverrides { + t.Fatal("expected binding allowUiOverrides to be false") + } + if !found.AllowBackendOverrides { + t.Fatal("expected binding allowBackendOverrides to be true") + } + if !found.AllowSsr { + t.Fatal("expected binding allowSsr to be true") + } + }) +} diff --git a/ui/rsbuild.config.ts b/ui/rsbuild.config.ts index 8a37905c..b829ffe5 100644 --- a/ui/rsbuild.config.ts +++ b/ui/rsbuild.config.ts @@ -260,7 +260,7 @@ function createServerConfig() { }, }, server: { - port: 3004, + port: Number(process.env.PORT) || 3004, printUrls: ({ urls }) => urls.filter((url) => url.includes("localhost")), headers: { "Access-Control-Allow-Origin": "*", diff --git a/ui/src/components/index.ts b/ui/src/components/index.ts index 476a1331..f7bcfd5c 100644 --- a/ui/src/components/index.ts +++ b/ui/src/components/index.ts @@ -9,6 +9,7 @@ export { ConfirmDialog } from "./confirm-dialog"; export { EmptyState } from "./empty-state"; export { PageContainer } from "./layout/page-container"; export { OrgSwitcher } from "./org-switcher"; +export { Avatar, AvatarFallback, AvatarImage } from "./ui/avatar"; export { Badge } from "./ui/badge"; export { Button } from "./ui/button"; export { diff --git a/ui/src/components/near-branding.tsx b/ui/src/components/near-branding.tsx new file mode 100644 index 00000000..e3454dcf --- /dev/null +++ b/ui/src/components/near-branding.tsx @@ -0,0 +1,24 @@ +import builtOn from "@/assets/built_on.png"; +import builtOnRev from "@/assets/built_on_rev.png"; + +export function NearBranding() { + return ( + <a + href="https://near.dev" + target="_blank" + rel="noopener noreferrer" + className="relative block h-5 w-[84px] mx-auto" + > + <img + src={builtOn} + alt="Built on NEAR" + className="absolute inset-0 h-full w-full object-contain dark:hidden" + /> + <img + src={builtOnRev} + alt="Built on NEAR" + className="absolute inset-0 hidden h-full w-full object-contain dark:block" + /> + </a> + ); +} diff --git a/ui/src/components/org-switcher.tsx b/ui/src/components/org-switcher.tsx index 8d4eada0..7760d57f 100644 --- a/ui/src/components/org-switcher.tsx +++ b/ui/src/components/org-switcher.tsx @@ -64,7 +64,7 @@ export function OrgSwitcher({ organizations, activeOrgId, onSwitch }: OrgSwitche )} <DropdownMenuSeparator /> <DropdownMenuItem asChild> - <Link to="/organizations/new" className="flex items-center gap-2 cursor-pointer"> + <Link to="/orgs/new" className="flex items-center gap-2 cursor-pointer"> <Plus className="h-3.5 w-3.5" /> new organization </Link> diff --git a/ui/src/components/theme-toggle.tsx b/ui/src/components/theme-toggle.tsx index 639cf74b..033fd9f0 100644 --- a/ui/src/components/theme-toggle.tsx +++ b/ui/src/components/theme-toggle.tsx @@ -1,5 +1,6 @@ import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; +import { cn } from "@/lib/utils"; export function ThemeToggle({ className }: { className?: string }) { const { setTheme, resolvedTheme } = useTheme(); @@ -12,7 +13,7 @@ export function ThemeToggle({ className }: { className?: string }) { <button type="button" onClick={toggleTheme} - className={className} + className={cn("relative", className)} aria-label={`Switch to ${resolvedTheme === "dark" ? "light" : "dark"} theme`} > <Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" /> diff --git a/ui/src/components/user-nav.tsx b/ui/src/components/user-nav.tsx index a77e1c08..084aca2f 100644 --- a/ui/src/components/user-nav.tsx +++ b/ui/src/components/user-nav.tsx @@ -1,17 +1,18 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate, useRouter } from "@tanstack/react-router"; +import { Building2, Home, LogOut, Settings, User } from "lucide-react"; import { useMemo } from "react"; import type { Organization } from "@/app"; import { sessionQueryOptions, useAuthClient } from "@/app"; -import { OrgSwitcher } from "@/components"; +import { Avatar, AvatarFallback, AvatarImage, OrgSwitcher } from "@/components"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { getNearInitials, resolveNearImageUrl } from "@/lib/near-profile"; export function UserNav() { const auth = useAuthClient(); @@ -20,6 +21,8 @@ export function UserNav() { const router = useRouter(); const { data: session } = useQuery(sessionQueryOptions(auth)); const user = session?.user; + const nearAccountId = auth.near.getAccountId(); + const { data: organizations } = useQuery({ queryKey: ["organizations"], queryFn: async () => { @@ -35,6 +38,16 @@ export function UserNav() { return organizations?.find((org) => org.id === activeOrgId); }, [organizations, activeOrgId]); + const { data: nearProfile } = useQuery({ + queryKey: ["near-profile", nearAccountId], + queryFn: async () => { + const { data } = await auth.near.getProfile(nearAccountId ?? undefined); + return data ?? null; + }, + enabled: !!nearAccountId, + staleTime: 5 * 60 * 1000, + }); + const signOutMutation = useMutation({ mutationFn: async () => { const { error } = await auth.signOut(); @@ -71,6 +84,28 @@ export function UserNav() { await queryClient.invalidateQueries({ queryKey: ["organizations"] }); }; + const avatarSrc = resolveNearImageUrl(nearProfile?.image) ?? user.image ?? undefined; + const validEmail = !user.isAnonymous && user.email ? user.email : null; + const displayName = nearProfile?.name || user.name || nearAccountId || validEmail || "guest"; + const handle = nearAccountId || validEmail || "anonymous session"; + const showHandle = handle !== displayName; + const initials = getNearInitials(nearProfile?.name || user.name || nearAccountId); + + const identityContent = ( + <> + <Avatar className="size-9 shrink-0 ring-1 ring-border"> + {avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null} + <AvatarFallback className="text-xs font-semibold"> + {initials || <User className="size-4" />} + </AvatarFallback> + </Avatar> + <div className="min-w-0 flex-1"> + <p className="truncate text-sm font-medium text-foreground">{displayName}</p> + {showHandle && <p className="truncate text-xs text-muted-foreground">{handle}</p>} + </div> + </> + ); + return ( <div className="flex items-center gap-2"> {organizations && organizations.length > 0 && ( @@ -85,30 +120,48 @@ export function UserNav() { <DropdownMenuTrigger asChild> <button type="button" - className="w-6 h-6 rounded-full! bg-foreground transition-all duration-200 ease-out hover:shadow-lg hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - title="menu" - /> + aria-label={displayName} + className="rounded-full! ring-1 ring-border transition-transform duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:scale-105" + title="account menu" + > + <Avatar className="size-8"> + {avatarSrc ? <AvatarImage src={avatarSrc} alt="" /> : null} + <AvatarFallback className="text-xs font-semibold"> + {initials || <User className="size-4" />} + </AvatarFallback> + </Avatar> + </button> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuLabel> - <div className="space-y-1"> - <p className="text-xs text-muted-foreground">signed in as</p> - <p className="truncate text-sm font-normal">{user.email || user.id}</p> - </div> - </DropdownMenuLabel> + <DropdownMenuContent align="end" className="w-64"> + <DropdownMenuItem asChild> + {nearAccountId ? ( + <Link to="/$accountId" params={{ accountId: nearAccountId }}> + {identityContent} + </Link> + ) : ( + <Link to="/settings/profile">{identityContent}</Link> + )} + </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem asChild> - <Link to="/home">workspace</Link> + <Link to="/dashboard"> + <Home /> + workspace + </Link> </DropdownMenuItem> {activeOrg && ( <DropdownMenuItem asChild> - <Link to="/organizations/$slug" params={{ slug: activeOrg.slug }}> + <Link to="/orgs/$slug" params={{ slug: activeOrg.slug }}> + <Building2 /> {activeOrg.name} </Link> </DropdownMenuItem> )} <DropdownMenuItem asChild> - <Link to="/settings">settings</Link> + <Link to="/settings"> + <Settings /> + settings + </Link> </DropdownMenuItem> <DropdownMenuSeparator /> <DropdownMenuItem @@ -119,6 +172,7 @@ export function UserNav() { }} disabled={signOutMutation.isPending} > + <LogOut /> {signOutMutation.isPending ? "signing out..." : "sign out"} </DropdownMenuItem> </DropdownMenuContent> diff --git a/ui/src/lib/near-profile.ts b/ui/src/lib/near-profile.ts new file mode 100644 index 00000000..deb01fa8 --- /dev/null +++ b/ui/src/lib/near-profile.ts @@ -0,0 +1,17 @@ +export interface NearProfileImage { + url?: string; + ipfs_cid?: string; +} + +export function resolveNearImageUrl(image?: NearProfileImage | null): string | undefined { + if (image?.url) return image.url; + if (image?.ipfs_cid) return `https://ipfs.near.social/ipfs/${image.ipfs_cid}`; + return undefined; +} + +export function getNearInitials(name?: string | null): string { + if (!name) return ""; + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + return name.trim().slice(0, 2).toUpperCase(); +} diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 8ee2490c..89244e6f 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -10,79 +10,79 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as LayoutRouteImport } from './routes/_layout' -import { Route as LayoutIndexRouteImport } from './routes/_layout/index' -import { Route as LayoutSkillRouteImport } from './routes/_layout/skill' -import { Route as LayoutLoginRouteImport } from './routes/_layout/login' -import { Route as LayoutAboutRouteImport } from './routes/_layout/about' +import { Route as LayoutPublicRouteImport } from './routes/_layout/_public' import { Route as LayoutAuthenticatedRouteImport } from './routes/_layout/_authenticated' -import { Route as LayoutThingsIndexRouteImport } from './routes/_layout/things/index' -import { Route as LayoutAppsIndexRouteImport } from './routes/_layout/apps/index' -import { Route as LayoutThingsLiveRouteImport } from './routes/_layout/things/live' -import { Route as LayoutThingsThingIdRouteImport } from './routes/_layout/things/$thingId' +import { Route as LayoutAnonRouteImport } from './routes/_layout/_anon' +import { Route as LayoutAdminRouteImport } from './routes/_layout/_admin' +import { Route as LayoutPublicIndexRouteImport } from './routes/_layout/_public/index' +import { Route as LayoutPublicSkillRouteImport } from './routes/_layout/_public/skill' +import { Route as LayoutPublicAboutRouteImport } from './routes/_layout/_public/about' +import { Route as LayoutPublicAccountIdRouteImport } from './routes/_layout/_public/$accountId' import { Route as LayoutAuthenticatedSettingsRouteImport } from './routes/_layout/_authenticated/settings' -import { Route as LayoutAuthenticatedHomeRouteImport } from './routes/_layout/_authenticated/home' -import { Route as LayoutAuthenticatedAdminRouteImport } from './routes/_layout/_authenticated/admin' -import { Route as LayoutAppsAccountIdIndexRouteImport } from './routes/_layout/apps/$accountId/index' +import { Route as LayoutAuthenticatedDashboardRouteImport } from './routes/_layout/_authenticated/dashboard' +import { Route as LayoutAnonLoginRouteImport } from './routes/_layout/_anon/login' +import { Route as LayoutAdminAdminRouteImport } from './routes/_layout/_admin/admin' +import { Route as LayoutPublicThingsIndexRouteImport } from './routes/_layout/_public/things/index' +import { Route as LayoutPublicAppsIndexRouteImport } from './routes/_layout/_public/apps/index' +import { Route as LayoutPublicAccountIdIndexRouteImport } from './routes/_layout/_public/$accountId/index' import { Route as LayoutAuthenticatedSettingsIndexRouteImport } from './routes/_layout/_authenticated/settings/index' -import { Route as LayoutAuthenticatedOrganizationsIndexRouteImport } from './routes/_layout/_authenticated/organizations/index' -import { Route as LayoutAppsAccountIdGatewayIdRouteImport } from './routes/_layout/apps/$accountId/$gatewayId' +import { Route as LayoutAuthenticatedOrgsIndexRouteImport } from './routes/_layout/_authenticated/orgs/index' +import { Route as LayoutAdminAdminIndexRouteImport } from './routes/_layout/_admin/admin/index' +import { Route as LayoutPublicThingsLiveRouteImport } from './routes/_layout/_public/things/live' +import { Route as LayoutPublicThingsThingIdRouteImport } from './routes/_layout/_public/things/$thingId' +import { Route as LayoutPublicAccountIdAppsRouteImport } from './routes/_layout/_public/$accountId/apps' import { Route as LayoutAuthenticatedThingsNewRouteImport } from './routes/_layout/_authenticated/things/new' import { Route as LayoutAuthenticatedTenantNewRouteImport } from './routes/_layout/_authenticated/tenant/new' import { Route as LayoutAuthenticatedTenantTenantIdRouteImport } from './routes/_layout/_authenticated/tenant/$tenantId' import { Route as LayoutAuthenticatedSettingsSecurityRouteImport } from './routes/_layout/_authenticated/settings/security' import { Route as LayoutAuthenticatedSettingsProfileRouteImport } from './routes/_layout/_authenticated/settings/profile' import { Route as LayoutAuthenticatedSettingsAuthMethodsRouteImport } from './routes/_layout/_authenticated/settings/auth-methods' -import { Route as LayoutAuthenticatedOrganizationsNewRouteImport } from './routes/_layout/_authenticated/organizations/new' -import { Route as LayoutAuthenticatedOrganizationsSlugRouteImport } from './routes/_layout/_authenticated/organizations/$slug' -import { Route as LayoutAuthenticatedAcceptInvitationIdRouteImport } from './routes/_layout/_authenticated/accept-invitation.$id' +import { Route as LayoutAuthenticatedOrgsNewRouteImport } from './routes/_layout/_authenticated/orgs/new' +import { Route as LayoutAuthenticatedOrgsSlugRouteImport } from './routes/_layout/_authenticated/orgs/$slug' +import { Route as LayoutAdminAdminSystemRouteImport } from './routes/_layout/_admin/admin/system' +import { Route as LayoutPublicAppsAccountIdIndexRouteImport } from './routes/_layout/_public/apps/$accountId/index' +import { Route as LayoutPublicAppsAccountIdGatewayIdRouteImport } from './routes/_layout/_public/apps/$accountId/$gatewayId' +import { Route as LayoutAuthenticatedOrgsInvitesIdRouteImport } from './routes/_layout/_authenticated/orgs/invites.$id' const LayoutRoute = LayoutRouteImport.update({ id: '/_layout', getParentRoute: () => rootRouteImport, } as any) -const LayoutIndexRoute = LayoutIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutSkillRoute = LayoutSkillRouteImport.update({ - id: '/skill', - path: '/skill', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutLoginRoute = LayoutLoginRouteImport.update({ - id: '/login', - path: '/login', - getParentRoute: () => LayoutRoute, -} as any) -const LayoutAboutRoute = LayoutAboutRouteImport.update({ - id: '/about', - path: '/about', +const LayoutPublicRoute = LayoutPublicRouteImport.update({ + id: '/_public', getParentRoute: () => LayoutRoute, } as any) const LayoutAuthenticatedRoute = LayoutAuthenticatedRouteImport.update({ id: '/_authenticated', getParentRoute: () => LayoutRoute, } as any) -const LayoutThingsIndexRoute = LayoutThingsIndexRouteImport.update({ - id: '/things/', - path: '/things/', +const LayoutAnonRoute = LayoutAnonRouteImport.update({ + id: '/_anon', getParentRoute: () => LayoutRoute, } as any) -const LayoutAppsIndexRoute = LayoutAppsIndexRouteImport.update({ - id: '/apps/', - path: '/apps/', +const LayoutAdminRoute = LayoutAdminRouteImport.update({ + id: '/_admin', getParentRoute: () => LayoutRoute, } as any) -const LayoutThingsLiveRoute = LayoutThingsLiveRouteImport.update({ - id: '/things/live', - path: '/things/live', - getParentRoute: () => LayoutRoute, +const LayoutPublicIndexRoute = LayoutPublicIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LayoutPublicRoute, } as any) -const LayoutThingsThingIdRoute = LayoutThingsThingIdRouteImport.update({ - id: '/things/$thingId', - path: '/things/$thingId', - getParentRoute: () => LayoutRoute, +const LayoutPublicSkillRoute = LayoutPublicSkillRouteImport.update({ + id: '/skill', + path: '/skill', + getParentRoute: () => LayoutPublicRoute, +} as any) +const LayoutPublicAboutRoute = LayoutPublicAboutRouteImport.update({ + id: '/about', + path: '/about', + getParentRoute: () => LayoutPublicRoute, +} as any) +const LayoutPublicAccountIdRoute = LayoutPublicAccountIdRouteImport.update({ + id: '/$accountId', + path: '/$accountId', + getParentRoute: () => LayoutPublicRoute, } as any) const LayoutAuthenticatedSettingsRoute = LayoutAuthenticatedSettingsRouteImport.update({ @@ -90,22 +90,37 @@ const LayoutAuthenticatedSettingsRoute = path: '/settings', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAuthenticatedHomeRoute = LayoutAuthenticatedHomeRouteImport.update({ - id: '/home', - path: '/home', - getParentRoute: () => LayoutAuthenticatedRoute, -} as any) -const LayoutAuthenticatedAdminRoute = - LayoutAuthenticatedAdminRouteImport.update({ - id: '/admin', - path: '/admin', +const LayoutAuthenticatedDashboardRoute = + LayoutAuthenticatedDashboardRouteImport.update({ + id: '/dashboard', + path: '/dashboard', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAppsAccountIdIndexRoute = - LayoutAppsAccountIdIndexRouteImport.update({ - id: '/apps/$accountId/', - path: '/apps/$accountId/', - getParentRoute: () => LayoutRoute, +const LayoutAnonLoginRoute = LayoutAnonLoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => LayoutAnonRoute, +} as any) +const LayoutAdminAdminRoute = LayoutAdminAdminRouteImport.update({ + id: '/admin', + path: '/admin', + getParentRoute: () => LayoutAdminRoute, +} as any) +const LayoutPublicThingsIndexRoute = LayoutPublicThingsIndexRouteImport.update({ + id: '/things/', + path: '/things/', + getParentRoute: () => LayoutPublicRoute, +} as any) +const LayoutPublicAppsIndexRoute = LayoutPublicAppsIndexRouteImport.update({ + id: '/apps/', + path: '/apps/', + getParentRoute: () => LayoutPublicRoute, +} as any) +const LayoutPublicAccountIdIndexRoute = + LayoutPublicAccountIdIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LayoutPublicAccountIdRoute, } as any) const LayoutAuthenticatedSettingsIndexRoute = LayoutAuthenticatedSettingsIndexRouteImport.update({ @@ -113,17 +128,33 @@ const LayoutAuthenticatedSettingsIndexRoute = path: '/', getParentRoute: () => LayoutAuthenticatedSettingsRoute, } as any) -const LayoutAuthenticatedOrganizationsIndexRoute = - LayoutAuthenticatedOrganizationsIndexRouteImport.update({ - id: '/organizations/', - path: '/organizations/', +const LayoutAuthenticatedOrgsIndexRoute = + LayoutAuthenticatedOrgsIndexRouteImport.update({ + id: '/orgs/', + path: '/orgs/', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAppsAccountIdGatewayIdRoute = - LayoutAppsAccountIdGatewayIdRouteImport.update({ - id: '/apps/$accountId/$gatewayId', - path: '/apps/$accountId/$gatewayId', - getParentRoute: () => LayoutRoute, +const LayoutAdminAdminIndexRoute = LayoutAdminAdminIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => LayoutAdminAdminRoute, +} as any) +const LayoutPublicThingsLiveRoute = LayoutPublicThingsLiveRouteImport.update({ + id: '/things/live', + path: '/things/live', + getParentRoute: () => LayoutPublicRoute, +} as any) +const LayoutPublicThingsThingIdRoute = + LayoutPublicThingsThingIdRouteImport.update({ + id: '/things/$thingId', + path: '/things/$thingId', + getParentRoute: () => LayoutPublicRoute, + } as any) +const LayoutPublicAccountIdAppsRoute = + LayoutPublicAccountIdAppsRouteImport.update({ + id: '/apps', + path: '/apps', + getParentRoute: () => LayoutPublicAccountIdRoute, } as any) const LayoutAuthenticatedThingsNewRoute = LayoutAuthenticatedThingsNewRouteImport.update({ @@ -161,185 +192,234 @@ const LayoutAuthenticatedSettingsAuthMethodsRoute = path: '/auth-methods', getParentRoute: () => LayoutAuthenticatedSettingsRoute, } as any) -const LayoutAuthenticatedOrganizationsNewRoute = - LayoutAuthenticatedOrganizationsNewRouteImport.update({ - id: '/organizations/new', - path: '/organizations/new', +const LayoutAuthenticatedOrgsNewRoute = + LayoutAuthenticatedOrgsNewRouteImport.update({ + id: '/orgs/new', + path: '/orgs/new', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAuthenticatedOrganizationsSlugRoute = - LayoutAuthenticatedOrganizationsSlugRouteImport.update({ - id: '/organizations/$slug', - path: '/organizations/$slug', +const LayoutAuthenticatedOrgsSlugRoute = + LayoutAuthenticatedOrgsSlugRouteImport.update({ + id: '/orgs/$slug', + path: '/orgs/$slug', getParentRoute: () => LayoutAuthenticatedRoute, } as any) -const LayoutAuthenticatedAcceptInvitationIdRoute = - LayoutAuthenticatedAcceptInvitationIdRouteImport.update({ - id: '/accept-invitation/$id', - path: '/accept-invitation/$id', +const LayoutAdminAdminSystemRoute = LayoutAdminAdminSystemRouteImport.update({ + id: '/system', + path: '/system', + getParentRoute: () => LayoutAdminAdminRoute, +} as any) +const LayoutPublicAppsAccountIdIndexRoute = + LayoutPublicAppsAccountIdIndexRouteImport.update({ + id: '/apps/$accountId/', + path: '/apps/$accountId/', + getParentRoute: () => LayoutPublicRoute, + } as any) +const LayoutPublicAppsAccountIdGatewayIdRoute = + LayoutPublicAppsAccountIdGatewayIdRouteImport.update({ + id: '/apps/$accountId/$gatewayId', + path: '/apps/$accountId/$gatewayId', + getParentRoute: () => LayoutPublicRoute, + } as any) +const LayoutAuthenticatedOrgsInvitesIdRoute = + LayoutAuthenticatedOrgsInvitesIdRouteImport.update({ + id: '/orgs/invites/$id', + path: '/orgs/invites/$id', getParentRoute: () => LayoutAuthenticatedRoute, } as any) export interface FileRoutesByFullPath { - '/': typeof LayoutIndexRoute - '/about': typeof LayoutAboutRoute - '/login': typeof LayoutLoginRoute - '/skill': typeof LayoutSkillRoute - '/admin': typeof LayoutAuthenticatedAdminRoute - '/home': typeof LayoutAuthenticatedHomeRoute + '/': typeof LayoutPublicIndexRoute + '/admin': typeof LayoutAdminAdminRouteWithChildren + '/login': typeof LayoutAnonLoginRoute + '/dashboard': typeof LayoutAuthenticatedDashboardRoute '/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren - '/things/$thingId': typeof LayoutThingsThingIdRoute - '/things/live': typeof LayoutThingsLiveRoute - '/apps/': typeof LayoutAppsIndexRoute - '/things/': typeof LayoutThingsIndexRoute - '/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute - '/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute + '/$accountId': typeof LayoutPublicAccountIdRouteWithChildren + '/about': typeof LayoutPublicAboutRoute + '/skill': typeof LayoutPublicSkillRoute + '/admin/system': typeof LayoutAdminAdminSystemRoute + '/orgs/$slug': typeof LayoutAuthenticatedOrgsSlugRoute + '/orgs/new': typeof LayoutAuthenticatedOrgsNewRoute '/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute '/settings/profile': typeof LayoutAuthenticatedSettingsProfileRoute '/settings/security': typeof LayoutAuthenticatedSettingsSecurityRoute '/tenant/$tenantId': typeof LayoutAuthenticatedTenantTenantIdRoute '/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/things/new': typeof LayoutAuthenticatedThingsNewRoute - '/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute - '/organizations/': typeof LayoutAuthenticatedOrganizationsIndexRoute + '/$accountId/apps': typeof LayoutPublicAccountIdAppsRoute + '/things/$thingId': typeof LayoutPublicThingsThingIdRoute + '/things/live': typeof LayoutPublicThingsLiveRoute + '/admin/': typeof LayoutAdminAdminIndexRoute + '/orgs/': typeof LayoutAuthenticatedOrgsIndexRoute '/settings/': typeof LayoutAuthenticatedSettingsIndexRoute - '/apps/$accountId/': typeof LayoutAppsAccountIdIndexRoute + '/$accountId/': typeof LayoutPublicAccountIdIndexRoute + '/apps/': typeof LayoutPublicAppsIndexRoute + '/things/': typeof LayoutPublicThingsIndexRoute + '/orgs/invites/$id': typeof LayoutAuthenticatedOrgsInvitesIdRoute + '/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute + '/apps/$accountId/': typeof LayoutPublicAppsAccountIdIndexRoute } export interface FileRoutesByTo { - '/': typeof LayoutIndexRoute - '/about': typeof LayoutAboutRoute - '/login': typeof LayoutLoginRoute - '/skill': typeof LayoutSkillRoute - '/admin': typeof LayoutAuthenticatedAdminRoute - '/home': typeof LayoutAuthenticatedHomeRoute - '/things/$thingId': typeof LayoutThingsThingIdRoute - '/things/live': typeof LayoutThingsLiveRoute - '/apps': typeof LayoutAppsIndexRoute - '/things': typeof LayoutThingsIndexRoute - '/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute - '/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute + '/': typeof LayoutPublicIndexRoute + '/login': typeof LayoutAnonLoginRoute + '/dashboard': typeof LayoutAuthenticatedDashboardRoute + '/about': typeof LayoutPublicAboutRoute + '/skill': typeof LayoutPublicSkillRoute + '/admin/system': typeof LayoutAdminAdminSystemRoute + '/orgs/$slug': typeof LayoutAuthenticatedOrgsSlugRoute + '/orgs/new': typeof LayoutAuthenticatedOrgsNewRoute '/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute '/settings/profile': typeof LayoutAuthenticatedSettingsProfileRoute '/settings/security': typeof LayoutAuthenticatedSettingsSecurityRoute '/tenant/$tenantId': typeof LayoutAuthenticatedTenantTenantIdRoute '/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/things/new': typeof LayoutAuthenticatedThingsNewRoute - '/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute - '/organizations': typeof LayoutAuthenticatedOrganizationsIndexRoute + '/$accountId/apps': typeof LayoutPublicAccountIdAppsRoute + '/things/$thingId': typeof LayoutPublicThingsThingIdRoute + '/things/live': typeof LayoutPublicThingsLiveRoute + '/admin': typeof LayoutAdminAdminIndexRoute + '/orgs': typeof LayoutAuthenticatedOrgsIndexRoute '/settings': typeof LayoutAuthenticatedSettingsIndexRoute - '/apps/$accountId': typeof LayoutAppsAccountIdIndexRoute + '/$accountId': typeof LayoutPublicAccountIdIndexRoute + '/apps': typeof LayoutPublicAppsIndexRoute + '/things': typeof LayoutPublicThingsIndexRoute + '/orgs/invites/$id': typeof LayoutAuthenticatedOrgsInvitesIdRoute + '/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute + '/apps/$accountId': typeof LayoutPublicAppsAccountIdIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_layout': typeof LayoutRouteWithChildren + '/_layout/_admin': typeof LayoutAdminRouteWithChildren + '/_layout/_anon': typeof LayoutAnonRouteWithChildren '/_layout/_authenticated': typeof LayoutAuthenticatedRouteWithChildren - '/_layout/about': typeof LayoutAboutRoute - '/_layout/login': typeof LayoutLoginRoute - '/_layout/skill': typeof LayoutSkillRoute - '/_layout/': typeof LayoutIndexRoute - '/_layout/_authenticated/admin': typeof LayoutAuthenticatedAdminRoute - '/_layout/_authenticated/home': typeof LayoutAuthenticatedHomeRoute + '/_layout/_public': typeof LayoutPublicRouteWithChildren + '/_layout/_admin/admin': typeof LayoutAdminAdminRouteWithChildren + '/_layout/_anon/login': typeof LayoutAnonLoginRoute + '/_layout/_authenticated/dashboard': typeof LayoutAuthenticatedDashboardRoute '/_layout/_authenticated/settings': typeof LayoutAuthenticatedSettingsRouteWithChildren - '/_layout/things/$thingId': typeof LayoutThingsThingIdRoute - '/_layout/things/live': typeof LayoutThingsLiveRoute - '/_layout/apps/': typeof LayoutAppsIndexRoute - '/_layout/things/': typeof LayoutThingsIndexRoute - '/_layout/_authenticated/accept-invitation/$id': typeof LayoutAuthenticatedAcceptInvitationIdRoute - '/_layout/_authenticated/organizations/$slug': typeof LayoutAuthenticatedOrganizationsSlugRoute - '/_layout/_authenticated/organizations/new': typeof LayoutAuthenticatedOrganizationsNewRoute + '/_layout/_public/$accountId': typeof LayoutPublicAccountIdRouteWithChildren + '/_layout/_public/about': typeof LayoutPublicAboutRoute + '/_layout/_public/skill': typeof LayoutPublicSkillRoute + '/_layout/_public/': typeof LayoutPublicIndexRoute + '/_layout/_admin/admin/system': typeof LayoutAdminAdminSystemRoute + '/_layout/_authenticated/orgs/$slug': typeof LayoutAuthenticatedOrgsSlugRoute + '/_layout/_authenticated/orgs/new': typeof LayoutAuthenticatedOrgsNewRoute '/_layout/_authenticated/settings/auth-methods': typeof LayoutAuthenticatedSettingsAuthMethodsRoute '/_layout/_authenticated/settings/profile': typeof LayoutAuthenticatedSettingsProfileRoute '/_layout/_authenticated/settings/security': typeof LayoutAuthenticatedSettingsSecurityRoute '/_layout/_authenticated/tenant/$tenantId': typeof LayoutAuthenticatedTenantTenantIdRoute '/_layout/_authenticated/tenant/new': typeof LayoutAuthenticatedTenantNewRoute '/_layout/_authenticated/things/new': typeof LayoutAuthenticatedThingsNewRoute - '/_layout/apps/$accountId/$gatewayId': typeof LayoutAppsAccountIdGatewayIdRoute - '/_layout/_authenticated/organizations/': typeof LayoutAuthenticatedOrganizationsIndexRoute + '/_layout/_public/$accountId/apps': typeof LayoutPublicAccountIdAppsRoute + '/_layout/_public/things/$thingId': typeof LayoutPublicThingsThingIdRoute + '/_layout/_public/things/live': typeof LayoutPublicThingsLiveRoute + '/_layout/_admin/admin/': typeof LayoutAdminAdminIndexRoute + '/_layout/_authenticated/orgs/': typeof LayoutAuthenticatedOrgsIndexRoute '/_layout/_authenticated/settings/': typeof LayoutAuthenticatedSettingsIndexRoute - '/_layout/apps/$accountId/': typeof LayoutAppsAccountIdIndexRoute + '/_layout/_public/$accountId/': typeof LayoutPublicAccountIdIndexRoute + '/_layout/_public/apps/': typeof LayoutPublicAppsIndexRoute + '/_layout/_public/things/': typeof LayoutPublicThingsIndexRoute + '/_layout/_authenticated/orgs/invites/$id': typeof LayoutAuthenticatedOrgsInvitesIdRoute + '/_layout/_public/apps/$accountId/$gatewayId': typeof LayoutPublicAppsAccountIdGatewayIdRoute + '/_layout/_public/apps/$accountId/': typeof LayoutPublicAppsAccountIdIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' - | '/about' - | '/login' - | '/skill' | '/admin' - | '/home' + | '/login' + | '/dashboard' | '/settings' - | '/things/$thingId' - | '/things/live' - | '/apps/' - | '/things/' - | '/accept-invitation/$id' - | '/organizations/$slug' - | '/organizations/new' + | '/$accountId' + | '/about' + | '/skill' + | '/admin/system' + | '/orgs/$slug' + | '/orgs/new' | '/settings/auth-methods' | '/settings/profile' | '/settings/security' | '/tenant/$tenantId' | '/tenant/new' | '/things/new' - | '/apps/$accountId/$gatewayId' - | '/organizations/' + | '/$accountId/apps' + | '/things/$thingId' + | '/things/live' + | '/admin/' + | '/orgs/' | '/settings/' + | '/$accountId/' + | '/apps/' + | '/things/' + | '/orgs/invites/$id' + | '/apps/$accountId/$gatewayId' | '/apps/$accountId/' fileRoutesByTo: FileRoutesByTo to: | '/' - | '/about' | '/login' + | '/dashboard' + | '/about' | '/skill' - | '/admin' - | '/home' - | '/things/$thingId' - | '/things/live' - | '/apps' - | '/things' - | '/accept-invitation/$id' - | '/organizations/$slug' - | '/organizations/new' + | '/admin/system' + | '/orgs/$slug' + | '/orgs/new' | '/settings/auth-methods' | '/settings/profile' | '/settings/security' | '/tenant/$tenantId' | '/tenant/new' | '/things/new' - | '/apps/$accountId/$gatewayId' - | '/organizations' + | '/$accountId/apps' + | '/things/$thingId' + | '/things/live' + | '/admin' + | '/orgs' | '/settings' + | '/$accountId' + | '/apps' + | '/things' + | '/orgs/invites/$id' + | '/apps/$accountId/$gatewayId' | '/apps/$accountId' id: | '__root__' | '/_layout' + | '/_layout/_admin' + | '/_layout/_anon' | '/_layout/_authenticated' - | '/_layout/about' - | '/_layout/login' - | '/_layout/skill' - | '/_layout/' - | '/_layout/_authenticated/admin' - | '/_layout/_authenticated/home' + | '/_layout/_public' + | '/_layout/_admin/admin' + | '/_layout/_anon/login' + | '/_layout/_authenticated/dashboard' | '/_layout/_authenticated/settings' - | '/_layout/things/$thingId' - | '/_layout/things/live' - | '/_layout/apps/' - | '/_layout/things/' - | '/_layout/_authenticated/accept-invitation/$id' - | '/_layout/_authenticated/organizations/$slug' - | '/_layout/_authenticated/organizations/new' + | '/_layout/_public/$accountId' + | '/_layout/_public/about' + | '/_layout/_public/skill' + | '/_layout/_public/' + | '/_layout/_admin/admin/system' + | '/_layout/_authenticated/orgs/$slug' + | '/_layout/_authenticated/orgs/new' | '/_layout/_authenticated/settings/auth-methods' | '/_layout/_authenticated/settings/profile' | '/_layout/_authenticated/settings/security' | '/_layout/_authenticated/tenant/$tenantId' | '/_layout/_authenticated/tenant/new' | '/_layout/_authenticated/things/new' - | '/_layout/apps/$accountId/$gatewayId' - | '/_layout/_authenticated/organizations/' + | '/_layout/_public/$accountId/apps' + | '/_layout/_public/things/$thingId' + | '/_layout/_public/things/live' + | '/_layout/_admin/admin/' + | '/_layout/_authenticated/orgs/' | '/_layout/_authenticated/settings/' - | '/_layout/apps/$accountId/' + | '/_layout/_public/$accountId/' + | '/_layout/_public/apps/' + | '/_layout/_public/things/' + | '/_layout/_authenticated/orgs/invites/$id' + | '/_layout/_public/apps/$accountId/$gatewayId' + | '/_layout/_public/apps/$accountId/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -355,32 +435,11 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutRouteImport parentRoute: typeof rootRouteImport } - '/_layout/': { - id: '/_layout/' - path: '/' + '/_layout/_public': { + id: '/_layout/_public' + path: '' fullPath: '/' - preLoaderRoute: typeof LayoutIndexRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/skill': { - id: '/_layout/skill' - path: '/skill' - fullPath: '/skill' - preLoaderRoute: typeof LayoutSkillRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/login': { - id: '/_layout/login' - path: '/login' - fullPath: '/login' - preLoaderRoute: typeof LayoutLoginRouteImport - parentRoute: typeof LayoutRoute - } - '/_layout/about': { - id: '/_layout/about' - path: '/about' - fullPath: '/about' - preLoaderRoute: typeof LayoutAboutRouteImport + preLoaderRoute: typeof LayoutPublicRouteImport parentRoute: typeof LayoutRoute } '/_layout/_authenticated': { @@ -390,33 +449,47 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedRouteImport parentRoute: typeof LayoutRoute } - '/_layout/things/': { - id: '/_layout/things/' - path: '/things' - fullPath: '/things/' - preLoaderRoute: typeof LayoutThingsIndexRouteImport + '/_layout/_anon': { + id: '/_layout/_anon' + path: '' + fullPath: '/' + preLoaderRoute: typeof LayoutAnonRouteImport parentRoute: typeof LayoutRoute } - '/_layout/apps/': { - id: '/_layout/apps/' - path: '/apps' - fullPath: '/apps/' - preLoaderRoute: typeof LayoutAppsIndexRouteImport + '/_layout/_admin': { + id: '/_layout/_admin' + path: '' + fullPath: '/' + preLoaderRoute: typeof LayoutAdminRouteImport parentRoute: typeof LayoutRoute } - '/_layout/things/live': { - id: '/_layout/things/live' - path: '/things/live' - fullPath: '/things/live' - preLoaderRoute: typeof LayoutThingsLiveRouteImport - parentRoute: typeof LayoutRoute + '/_layout/_public/': { + id: '/_layout/_public/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof LayoutPublicIndexRouteImport + parentRoute: typeof LayoutPublicRoute } - '/_layout/things/$thingId': { - id: '/_layout/things/$thingId' - path: '/things/$thingId' - fullPath: '/things/$thingId' - preLoaderRoute: typeof LayoutThingsThingIdRouteImport - parentRoute: typeof LayoutRoute + '/_layout/_public/skill': { + id: '/_layout/_public/skill' + path: '/skill' + fullPath: '/skill' + preLoaderRoute: typeof LayoutPublicSkillRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/about': { + id: '/_layout/_public/about' + path: '/about' + fullPath: '/about' + preLoaderRoute: typeof LayoutPublicAboutRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/$accountId': { + id: '/_layout/_public/$accountId' + path: '/$accountId' + fullPath: '/$accountId' + preLoaderRoute: typeof LayoutPublicAccountIdRouteImport + parentRoute: typeof LayoutPublicRoute } '/_layout/_authenticated/settings': { id: '/_layout/_authenticated/settings' @@ -425,26 +498,47 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedSettingsRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/home': { - id: '/_layout/_authenticated/home' - path: '/home' - fullPath: '/home' - preLoaderRoute: typeof LayoutAuthenticatedHomeRouteImport + '/_layout/_authenticated/dashboard': { + id: '/_layout/_authenticated/dashboard' + path: '/dashboard' + fullPath: '/dashboard' + preLoaderRoute: typeof LayoutAuthenticatedDashboardRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/admin': { - id: '/_layout/_authenticated/admin' + '/_layout/_anon/login': { + id: '/_layout/_anon/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LayoutAnonLoginRouteImport + parentRoute: typeof LayoutAnonRoute + } + '/_layout/_admin/admin': { + id: '/_layout/_admin/admin' path: '/admin' fullPath: '/admin' - preLoaderRoute: typeof LayoutAuthenticatedAdminRouteImport - parentRoute: typeof LayoutAuthenticatedRoute + preLoaderRoute: typeof LayoutAdminAdminRouteImport + parentRoute: typeof LayoutAdminRoute } - '/_layout/apps/$accountId/': { - id: '/_layout/apps/$accountId/' - path: '/apps/$accountId' - fullPath: '/apps/$accountId/' - preLoaderRoute: typeof LayoutAppsAccountIdIndexRouteImport - parentRoute: typeof LayoutRoute + '/_layout/_public/things/': { + id: '/_layout/_public/things/' + path: '/things' + fullPath: '/things/' + preLoaderRoute: typeof LayoutPublicThingsIndexRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/apps/': { + id: '/_layout/_public/apps/' + path: '/apps' + fullPath: '/apps/' + preLoaderRoute: typeof LayoutPublicAppsIndexRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/$accountId/': { + id: '/_layout/_public/$accountId/' + path: '/' + fullPath: '/$accountId/' + preLoaderRoute: typeof LayoutPublicAccountIdIndexRouteImport + parentRoute: typeof LayoutPublicAccountIdRoute } '/_layout/_authenticated/settings/': { id: '/_layout/_authenticated/settings/' @@ -453,19 +547,40 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedSettingsIndexRouteImport parentRoute: typeof LayoutAuthenticatedSettingsRoute } - '/_layout/_authenticated/organizations/': { - id: '/_layout/_authenticated/organizations/' - path: '/organizations' - fullPath: '/organizations/' - preLoaderRoute: typeof LayoutAuthenticatedOrganizationsIndexRouteImport + '/_layout/_authenticated/orgs/': { + id: '/_layout/_authenticated/orgs/' + path: '/orgs' + fullPath: '/orgs/' + preLoaderRoute: typeof LayoutAuthenticatedOrgsIndexRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/apps/$accountId/$gatewayId': { - id: '/_layout/apps/$accountId/$gatewayId' - path: '/apps/$accountId/$gatewayId' - fullPath: '/apps/$accountId/$gatewayId' - preLoaderRoute: typeof LayoutAppsAccountIdGatewayIdRouteImport - parentRoute: typeof LayoutRoute + '/_layout/_admin/admin/': { + id: '/_layout/_admin/admin/' + path: '/' + fullPath: '/admin/' + preLoaderRoute: typeof LayoutAdminAdminIndexRouteImport + parentRoute: typeof LayoutAdminAdminRoute + } + '/_layout/_public/things/live': { + id: '/_layout/_public/things/live' + path: '/things/live' + fullPath: '/things/live' + preLoaderRoute: typeof LayoutPublicThingsLiveRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/things/$thingId': { + id: '/_layout/_public/things/$thingId' + path: '/things/$thingId' + fullPath: '/things/$thingId' + preLoaderRoute: typeof LayoutPublicThingsThingIdRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/$accountId/apps': { + id: '/_layout/_public/$accountId/apps' + path: '/apps' + fullPath: '/$accountId/apps' + preLoaderRoute: typeof LayoutPublicAccountIdAppsRouteImport + parentRoute: typeof LayoutPublicAccountIdRoute } '/_layout/_authenticated/things/new': { id: '/_layout/_authenticated/things/new' @@ -509,30 +624,88 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutAuthenticatedSettingsAuthMethodsRouteImport parentRoute: typeof LayoutAuthenticatedSettingsRoute } - '/_layout/_authenticated/organizations/new': { - id: '/_layout/_authenticated/organizations/new' - path: '/organizations/new' - fullPath: '/organizations/new' - preLoaderRoute: typeof LayoutAuthenticatedOrganizationsNewRouteImport + '/_layout/_authenticated/orgs/new': { + id: '/_layout/_authenticated/orgs/new' + path: '/orgs/new' + fullPath: '/orgs/new' + preLoaderRoute: typeof LayoutAuthenticatedOrgsNewRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/organizations/$slug': { - id: '/_layout/_authenticated/organizations/$slug' - path: '/organizations/$slug' - fullPath: '/organizations/$slug' - preLoaderRoute: typeof LayoutAuthenticatedOrganizationsSlugRouteImport + '/_layout/_authenticated/orgs/$slug': { + id: '/_layout/_authenticated/orgs/$slug' + path: '/orgs/$slug' + fullPath: '/orgs/$slug' + preLoaderRoute: typeof LayoutAuthenticatedOrgsSlugRouteImport parentRoute: typeof LayoutAuthenticatedRoute } - '/_layout/_authenticated/accept-invitation/$id': { - id: '/_layout/_authenticated/accept-invitation/$id' - path: '/accept-invitation/$id' - fullPath: '/accept-invitation/$id' - preLoaderRoute: typeof LayoutAuthenticatedAcceptInvitationIdRouteImport + '/_layout/_admin/admin/system': { + id: '/_layout/_admin/admin/system' + path: '/system' + fullPath: '/admin/system' + preLoaderRoute: typeof LayoutAdminAdminSystemRouteImport + parentRoute: typeof LayoutAdminAdminRoute + } + '/_layout/_public/apps/$accountId/': { + id: '/_layout/_public/apps/$accountId/' + path: '/apps/$accountId' + fullPath: '/apps/$accountId/' + preLoaderRoute: typeof LayoutPublicAppsAccountIdIndexRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_public/apps/$accountId/$gatewayId': { + id: '/_layout/_public/apps/$accountId/$gatewayId' + path: '/apps/$accountId/$gatewayId' + fullPath: '/apps/$accountId/$gatewayId' + preLoaderRoute: typeof LayoutPublicAppsAccountIdGatewayIdRouteImport + parentRoute: typeof LayoutPublicRoute + } + '/_layout/_authenticated/orgs/invites/$id': { + id: '/_layout/_authenticated/orgs/invites/$id' + path: '/orgs/invites/$id' + fullPath: '/orgs/invites/$id' + preLoaderRoute: typeof LayoutAuthenticatedOrgsInvitesIdRouteImport parentRoute: typeof LayoutAuthenticatedRoute } } } +interface LayoutAdminAdminRouteChildren { + LayoutAdminAdminSystemRoute: typeof LayoutAdminAdminSystemRoute + LayoutAdminAdminIndexRoute: typeof LayoutAdminAdminIndexRoute +} + +const LayoutAdminAdminRouteChildren: LayoutAdminAdminRouteChildren = { + LayoutAdminAdminSystemRoute: LayoutAdminAdminSystemRoute, + LayoutAdminAdminIndexRoute: LayoutAdminAdminIndexRoute, +} + +const LayoutAdminAdminRouteWithChildren = + LayoutAdminAdminRoute._addFileChildren(LayoutAdminAdminRouteChildren) + +interface LayoutAdminRouteChildren { + LayoutAdminAdminRoute: typeof LayoutAdminAdminRouteWithChildren +} + +const LayoutAdminRouteChildren: LayoutAdminRouteChildren = { + LayoutAdminAdminRoute: LayoutAdminAdminRouteWithChildren, +} + +const LayoutAdminRouteWithChildren = LayoutAdminRoute._addFileChildren( + LayoutAdminRouteChildren, +) + +interface LayoutAnonRouteChildren { + LayoutAnonLoginRoute: typeof LayoutAnonLoginRoute +} + +const LayoutAnonRouteChildren: LayoutAnonRouteChildren = { + LayoutAnonLoginRoute: LayoutAnonLoginRoute, +} + +const LayoutAnonRouteWithChildren = LayoutAnonRoute._addFileChildren( + LayoutAnonRouteChildren, +) + interface LayoutAuthenticatedSettingsRouteChildren { LayoutAuthenticatedSettingsAuthMethodsRoute: typeof LayoutAuthenticatedSettingsAuthMethodsRoute LayoutAuthenticatedSettingsProfileRoute: typeof LayoutAuthenticatedSettingsProfileRoute @@ -558,66 +731,92 @@ const LayoutAuthenticatedSettingsRouteWithChildren = ) interface LayoutAuthenticatedRouteChildren { - LayoutAuthenticatedAdminRoute: typeof LayoutAuthenticatedAdminRoute - LayoutAuthenticatedHomeRoute: typeof LayoutAuthenticatedHomeRoute + LayoutAuthenticatedDashboardRoute: typeof LayoutAuthenticatedDashboardRoute LayoutAuthenticatedSettingsRoute: typeof LayoutAuthenticatedSettingsRouteWithChildren - LayoutAuthenticatedAcceptInvitationIdRoute: typeof LayoutAuthenticatedAcceptInvitationIdRoute - LayoutAuthenticatedOrganizationsSlugRoute: typeof LayoutAuthenticatedOrganizationsSlugRoute - LayoutAuthenticatedOrganizationsNewRoute: typeof LayoutAuthenticatedOrganizationsNewRoute + LayoutAuthenticatedOrgsSlugRoute: typeof LayoutAuthenticatedOrgsSlugRoute + LayoutAuthenticatedOrgsNewRoute: typeof LayoutAuthenticatedOrgsNewRoute LayoutAuthenticatedTenantTenantIdRoute: typeof LayoutAuthenticatedTenantTenantIdRoute LayoutAuthenticatedTenantNewRoute: typeof LayoutAuthenticatedTenantNewRoute LayoutAuthenticatedThingsNewRoute: typeof LayoutAuthenticatedThingsNewRoute - LayoutAuthenticatedOrganizationsIndexRoute: typeof LayoutAuthenticatedOrganizationsIndexRoute + LayoutAuthenticatedOrgsIndexRoute: typeof LayoutAuthenticatedOrgsIndexRoute + LayoutAuthenticatedOrgsInvitesIdRoute: typeof LayoutAuthenticatedOrgsInvitesIdRoute } const LayoutAuthenticatedRouteChildren: LayoutAuthenticatedRouteChildren = { - LayoutAuthenticatedAdminRoute: LayoutAuthenticatedAdminRoute, - LayoutAuthenticatedHomeRoute: LayoutAuthenticatedHomeRoute, + LayoutAuthenticatedDashboardRoute: LayoutAuthenticatedDashboardRoute, LayoutAuthenticatedSettingsRoute: LayoutAuthenticatedSettingsRouteWithChildren, - LayoutAuthenticatedAcceptInvitationIdRoute: - LayoutAuthenticatedAcceptInvitationIdRoute, - LayoutAuthenticatedOrganizationsSlugRoute: - LayoutAuthenticatedOrganizationsSlugRoute, - LayoutAuthenticatedOrganizationsNewRoute: - LayoutAuthenticatedOrganizationsNewRoute, + LayoutAuthenticatedOrgsSlugRoute: LayoutAuthenticatedOrgsSlugRoute, + LayoutAuthenticatedOrgsNewRoute: LayoutAuthenticatedOrgsNewRoute, LayoutAuthenticatedTenantTenantIdRoute: LayoutAuthenticatedTenantTenantIdRoute, LayoutAuthenticatedTenantNewRoute: LayoutAuthenticatedTenantNewRoute, LayoutAuthenticatedThingsNewRoute: LayoutAuthenticatedThingsNewRoute, - LayoutAuthenticatedOrganizationsIndexRoute: - LayoutAuthenticatedOrganizationsIndexRoute, + LayoutAuthenticatedOrgsIndexRoute: LayoutAuthenticatedOrgsIndexRoute, + LayoutAuthenticatedOrgsInvitesIdRoute: LayoutAuthenticatedOrgsInvitesIdRoute, } const LayoutAuthenticatedRouteWithChildren = LayoutAuthenticatedRoute._addFileChildren(LayoutAuthenticatedRouteChildren) +interface LayoutPublicAccountIdRouteChildren { + LayoutPublicAccountIdAppsRoute: typeof LayoutPublicAccountIdAppsRoute + LayoutPublicAccountIdIndexRoute: typeof LayoutPublicAccountIdIndexRoute +} + +const LayoutPublicAccountIdRouteChildren: LayoutPublicAccountIdRouteChildren = { + LayoutPublicAccountIdAppsRoute: LayoutPublicAccountIdAppsRoute, + LayoutPublicAccountIdIndexRoute: LayoutPublicAccountIdIndexRoute, +} + +const LayoutPublicAccountIdRouteWithChildren = + LayoutPublicAccountIdRoute._addFileChildren( + LayoutPublicAccountIdRouteChildren, + ) + +interface LayoutPublicRouteChildren { + LayoutPublicAccountIdRoute: typeof LayoutPublicAccountIdRouteWithChildren + LayoutPublicAboutRoute: typeof LayoutPublicAboutRoute + LayoutPublicSkillRoute: typeof LayoutPublicSkillRoute + LayoutPublicIndexRoute: typeof LayoutPublicIndexRoute + LayoutPublicThingsThingIdRoute: typeof LayoutPublicThingsThingIdRoute + LayoutPublicThingsLiveRoute: typeof LayoutPublicThingsLiveRoute + LayoutPublicAppsIndexRoute: typeof LayoutPublicAppsIndexRoute + LayoutPublicThingsIndexRoute: typeof LayoutPublicThingsIndexRoute + LayoutPublicAppsAccountIdGatewayIdRoute: typeof LayoutPublicAppsAccountIdGatewayIdRoute + LayoutPublicAppsAccountIdIndexRoute: typeof LayoutPublicAppsAccountIdIndexRoute +} + +const LayoutPublicRouteChildren: LayoutPublicRouteChildren = { + LayoutPublicAccountIdRoute: LayoutPublicAccountIdRouteWithChildren, + LayoutPublicAboutRoute: LayoutPublicAboutRoute, + LayoutPublicSkillRoute: LayoutPublicSkillRoute, + LayoutPublicIndexRoute: LayoutPublicIndexRoute, + LayoutPublicThingsThingIdRoute: LayoutPublicThingsThingIdRoute, + LayoutPublicThingsLiveRoute: LayoutPublicThingsLiveRoute, + LayoutPublicAppsIndexRoute: LayoutPublicAppsIndexRoute, + LayoutPublicThingsIndexRoute: LayoutPublicThingsIndexRoute, + LayoutPublicAppsAccountIdGatewayIdRoute: + LayoutPublicAppsAccountIdGatewayIdRoute, + LayoutPublicAppsAccountIdIndexRoute: LayoutPublicAppsAccountIdIndexRoute, +} + +const LayoutPublicRouteWithChildren = LayoutPublicRoute._addFileChildren( + LayoutPublicRouteChildren, +) + interface LayoutRouteChildren { + LayoutAdminRoute: typeof LayoutAdminRouteWithChildren + LayoutAnonRoute: typeof LayoutAnonRouteWithChildren LayoutAuthenticatedRoute: typeof LayoutAuthenticatedRouteWithChildren - LayoutAboutRoute: typeof LayoutAboutRoute - LayoutLoginRoute: typeof LayoutLoginRoute - LayoutSkillRoute: typeof LayoutSkillRoute - LayoutIndexRoute: typeof LayoutIndexRoute - LayoutThingsThingIdRoute: typeof LayoutThingsThingIdRoute - LayoutThingsLiveRoute: typeof LayoutThingsLiveRoute - LayoutAppsIndexRoute: typeof LayoutAppsIndexRoute - LayoutThingsIndexRoute: typeof LayoutThingsIndexRoute - LayoutAppsAccountIdGatewayIdRoute: typeof LayoutAppsAccountIdGatewayIdRoute - LayoutAppsAccountIdIndexRoute: typeof LayoutAppsAccountIdIndexRoute + LayoutPublicRoute: typeof LayoutPublicRouteWithChildren } const LayoutRouteChildren: LayoutRouteChildren = { + LayoutAdminRoute: LayoutAdminRouteWithChildren, + LayoutAnonRoute: LayoutAnonRouteWithChildren, LayoutAuthenticatedRoute: LayoutAuthenticatedRouteWithChildren, - LayoutAboutRoute: LayoutAboutRoute, - LayoutLoginRoute: LayoutLoginRoute, - LayoutSkillRoute: LayoutSkillRoute, - LayoutIndexRoute: LayoutIndexRoute, - LayoutThingsThingIdRoute: LayoutThingsThingIdRoute, - LayoutThingsLiveRoute: LayoutThingsLiveRoute, - LayoutAppsIndexRoute: LayoutAppsIndexRoute, - LayoutThingsIndexRoute: LayoutThingsIndexRoute, - LayoutAppsAccountIdGatewayIdRoute: LayoutAppsAccountIdGatewayIdRoute, - LayoutAppsAccountIdIndexRoute: LayoutAppsAccountIdIndexRoute, + LayoutPublicRoute: LayoutPublicRouteWithChildren, } const LayoutRouteWithChildren = diff --git a/ui/src/routes/__root.tsx b/ui/src/routes/__root.tsx index 20446243..ea983de9 100644 --- a/ui/src/routes/__root.tsx +++ b/ui/src/routes/__root.tsx @@ -16,7 +16,7 @@ import { Scripts, } from "@tanstack/react-router"; import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; -import { getRemoteScripts } from "everything-dev/ui/head"; +import { getRemoteScripts, getThemeInitScript } from "everything-dev/ui/head"; import { getSocialImageMeta } from "everything-dev/ui/metadata"; import { ThemeProvider } from "next-themes"; import type { RouterContext } from "@/app"; @@ -123,6 +123,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({ ...(typeof window === "undefined" ? [{ children: "window.__EVERYTHING_DEV_SSR__=true" }] : []), + getThemeInitScript(), ...getRemoteScripts({ runtimeConfig: runtimeConfig ?? undefined, containerName: "ui", diff --git a/ui/src/routes/_layout.tsx b/ui/src/routes/_layout.tsx index db4a8ec0..3f68e524 100644 --- a/ui/src/routes/_layout.tsx +++ b/ui/src/routes/_layout.tsx @@ -1,97 +1,17 @@ -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, Link, Outlet, useRouterState } from "@tanstack/react-router"; -import { ClipboardList, Compass, Globe, Home, Menu, Shield, X } from "lucide-react"; +import { createFileRoute, Outlet, useRouterState } from "@tanstack/react-router"; +import { X } from "lucide-react"; import { useEffect, useState } from "react"; -import { - getAccount, - getActiveRuntime, - getAppName, - sessionQueryOptions, - useAuthClient, -} from "@/app"; -import builtOn from "@/assets/built_on.png"; -import builtOnRev from "@/assets/built_on_rev.png"; -import { ThemeToggle } from "@/components/theme-toggle"; -import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { UserNav } from "@/components/user-nav"; -import { cn } from "@/lib/utils"; - -type SidebarRole = "anon" | "member" | "admin"; - -interface SidebarItem { - icon: React.ComponentType<{ className?: string }>; - label: string; - to: string; - roleRequired: SidebarRole; -} - -function filterSidebarByRole(items: SidebarItem[], userRole: SidebarRole): SidebarItem[] { - return items.filter((item) => { - if (item.roleRequired === "anon") return true; - if (item.roleRequired === "member" && userRole !== "anon") return true; - if (item.roleRequired === "admin" && userRole === "admin") return true; - return false; - }); -} - -function getUserRole(isAuthenticated: boolean, isAdmin: boolean): SidebarRole { - if (isAdmin) return "admin"; - if (isAuthenticated) return "member"; - return "anon"; -} +import { TooltipProvider } from "@/components/ui/tooltip"; export const Route = createFileRoute("/_layout")({ - beforeLoad: async ({ context }) => { - const { queryClient, authClient, apiClient } = context; - const session = await queryClient.ensureQueryData( - sessionQueryOptions(authClient, context.session), - ); - - const accountId = getAccount(context.runtimeConfig); - const tenant = await apiClient.resolveTenant({ accountId }); - - return { - runtimeConfig: context.runtimeConfig, - session, - tenant, - }; - }, component: Layout, }); function Layout() { - const pathname = useRouterState({ select: (s) => s.location.pathname }); const isNavigating = useRouterState({ select: (s) => s.status === "pending" }); - const { runtimeConfig, session, tenant } = Route.useRouteContext(); - const appName = getAppName(runtimeConfig); - const runtime = getActiveRuntime(runtimeConfig); - const account = getAccount(runtimeConfig); - const auth = useAuthClient(); - const isAuthenticated = !!session?.user; - const userRole = getUserRole(isAuthenticated, session?.user?.role === "admin"); - - const { data: liveSession } = useQuery({ - ...sessionQueryOptions(auth, session), - initialData: session, - }); - const activeOrgId = liveSession?.session?.activeOrganizationId ?? null; - const liveIsTenantMember = !!tenant && !!activeOrgId && activeOrgId === tenant.orgId; - - const hideSidebar = pathname === "/login"; - const sidebarItems: SidebarItem[] = [ - { icon: Home, label: "home", to: "/home", roleRequired: "anon" }, - { icon: Globe, label: "apps", to: "/apps", roleRequired: "anon" }, - ...(liveIsTenantMember - ? ([{ icon: Shield, label: "admin", to: "/admin", roleRequired: "member" }] as SidebarItem[]) - : []), - ]; - const visibleItems = filterSidebarByRole(sidebarItems, userRole); - - const isActive = (item: SidebarItem) => { - return pathname === item.to || (item.to !== "/" && pathname.startsWith(`${item.to}/`)); - }; + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); const [betaBannerDismissed, setBetaBannerDismissed] = useState(() => { if (typeof window === "undefined") return false; @@ -128,303 +48,14 @@ function Layout() { </div> )} - {isNavigating && ( + {mounted && isNavigating && ( <div className="fixed top-0 left-0 right-0 h-[2px] z-50 overflow-hidden pointer-events-none"> <div className="h-full bg-foreground animate-progress-bar" style={{ width: "100%" }} /> </div> )} - <header - className={cn( - "shrink-0 bg-card/50 border-b border-border transition-all duration-200 overflow-hidden", - hideSidebar ? "h-0 opacity-0 border-b-0" : "h-12 opacity-100", - )} - > - <div className="flex items-center justify-between px-4 sm:px-6 h-12"> - {isAuthenticated ? ( - <div className="flex items-center gap-2 text-xs text-muted-foreground font-mono min-w-0"> - <Link - aria-label={`${appName} home`} - className="sm:hidden flex items-center justify-center w-8 h-8 border-2 border-outset border-border-strong bg-card shadow-sm transition-shadow duration-200 hover:shadow-md" - to="/" - preload="intent" - > - <svg - viewBox="0 0 24 24" - fill="currentColor" - className="w-4 h-4 text-foreground" - aria-label={`${appName} logo`} - > - <title>{appName} - - - - -
- {tenant && ( - <> - {tenant.name} - / - - )} - {runtime?.accountId ?? account} - / - - {pathname === "/" ? "home" : pathname.slice(1).split("/").join(" / ")} - -
- - ) : ( - - - {appName} - - - - )} - -
- -
- - - {hideSidebar && } - -
- - -
-
- -
-
-
- -
- -
- - {!isAuthenticated && !hideSidebar && ( -
- -
- )} - -
- -
+ ); } - -function MinimalHeader() { - return ( -
- -
- ); -} - -function NearBranding() { - return ( - - Built on NEAR - Built on NEAR - - ); -} - -function MobileTabBar({ - isAuthenticated, - visibleItems, - isActive, -}: { - isAuthenticated: boolean; - visibleItems: SidebarItem[]; - isActive: (item: SidebarItem) => boolean; -}) { - const [drawerOpen, setDrawerOpen] = useState(false); - const pathname = useRouterState({ select: (s) => s.location.pathname }); - const tabActive = (to: string) => (to === "/" ? pathname === "/" : pathname.startsWith(to)); - - return ( - - ); -} - -function TabItem({ - to, - icon: Icon, - label, - active, -}: { - to: string; - icon: React.ComponentType<{ className?: string }>; - label: string; - active: boolean; -}) { - return ( - - - {label} - - ); -} diff --git a/ui/src/routes/_layout/_admin.tsx b/ui/src/routes/_layout/_admin.tsx new file mode 100644 index 00000000..9906591e --- /dev/null +++ b/ui/src/routes/_layout/_admin.tsx @@ -0,0 +1,62 @@ +import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; +import type { SessionData } from "@/app"; +import { sessionQueryOptions } from "@/app"; + +interface AuthContext { + isAuthenticated: boolean; + user: SessionData["user"] | null; + session: SessionData["session"] | null; + activeOrganizationId: string | null; + isAnonymous: boolean; + isAdmin: boolean; + isBanned: boolean; +} + +export const Route = createFileRoute("/_layout/_admin")({ + beforeLoad: async ({ context, location }) => { + const { queryClient, authClient } = context; + + const session = await queryClient.ensureQueryData( + sessionQueryOptions(authClient, context.session), + ); + + if (!session?.user) { + throw redirect({ + to: "/login", + search: { + redirect: location.href, + }, + }); + } + + if (session.user.banned) { + throw redirect({ + to: "/login", + hash: "banned", + }); + } + + if (session.user.role !== "admin") { + throw redirect({ to: "/dashboard" }); + } + + const auth: AuthContext = { + isAuthenticated: true, + user: session.user, + session: session.session, + activeOrganizationId: session.session?.activeOrganizationId || null, + isAnonymous: session.user.isAnonymous || false, + isAdmin: session.user.role === "admin", + isBanned: session.user.banned || false, + }; + return { + auth, + session, + }; + }, + component: AdminGate, +}); + +function AdminGate() { + return ; +} diff --git a/ui/src/routes/_layout/_authenticated/admin.tsx b/ui/src/routes/_layout/_admin/admin.tsx similarity index 64% rename from ui/src/routes/_layout/_authenticated/admin.tsx rename to ui/src/routes/_layout/_admin/admin.tsx index a044a4f4..9fee1727 100644 --- a/ui/src/routes/_layout/_authenticated/admin.tsx +++ b/ui/src/routes/_layout/_admin/admin.tsx @@ -1,12 +1,10 @@ -import { createFileRoute, Link, redirect } from "@tanstack/react-router"; -import { Shield, Users } from "lucide-react"; +import { createFileRoute, Link, Outlet, redirect } from "@tanstack/react-router"; +import { Shield } from "lucide-react"; import { getAccount } from "@/app"; -import { Card } from "@/components"; import { EmptyState } from "@/components/empty-state"; import { PageContainer } from "@/components/layout/page-container"; -import { InfoRow } from "@/components/ui/info-row"; -export const Route = createFileRoute("/_layout/_authenticated/admin")({ +export const Route = createFileRoute("/_layout/_admin/admin")({ head: () => ({ meta: [{ title: "Admin | app" }], }), @@ -57,7 +55,7 @@ function AdminPage() { home organizations @@ -95,7 +93,7 @@ function AdminPage() { label="Organization" value={ @@ -109,42 +107,7 @@ function AdminPage() { /> -
- - -
- Configuration -
-
- - - - - -
-
-
- -
- - -

- This tenant is backed by an organization. Manage members, roles, and invitations - there. -

- - - open organization - -
-
+ ); @@ -172,12 +135,3 @@ function StatCard({ ); } - -function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) { - return ( -
-

{title}

- {action} -
- ); -} diff --git a/ui/src/routes/_layout/_admin/admin/index.tsx b/ui/src/routes/_layout/_admin/admin/index.tsx new file mode 100644 index 00000000..137d4e33 --- /dev/null +++ b/ui/src/routes/_layout/_admin/admin/index.tsx @@ -0,0 +1,152 @@ +import { createFileRoute, Link } from "@tanstack/react-router"; +import { Building2, Settings, Users } from "lucide-react"; +import { getAccount } from "@/app"; +import { Button, Card } from "@/components"; +import { InfoRow } from "@/components/ui/info-row"; + +export const Route = createFileRoute("/_layout/_admin/admin/")({ + head: () => ({ + meta: [{ title: "Admin Dashboard | app" }], + }), + component: AdminDashboard, +}); + +function AdminDashboard() { + const { auth, tenant } = Route.useRouteContext(); + const account = getAccount(); + const user = auth?.user ?? null; + + return ( +
+
+
+
+

+ Dashboard +

+

+ Signed in as {account} +

+
+
+
+ +
+ + + + +
+ +
+

Manage

+
+ +
+ +
+

Organizations

+

+ Manage organizations, members, roles, and invitations. +

+ +
+ + +
+ +
+

Settings

+

+ Update your profile, auth methods, and security preferences. +

+ +
+
+
+ + {tenant && ( +
+ + +
+ Configuration +
+
+ + + + + +
+
+
+ )} + + {tenant && ( +
+ + +

+ This tenant is backed by an organization. Manage members, roles, and invitations + there. +

+ + + open organization + +
+
+ )} +
+ ); +} + +function StatCard({ + label, + value, + mono, +}: { + label: string; + value: React.ReactNode; + mono?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function SectionHeader({ title, action }: { title: string; action?: React.ReactNode }) { + return ( +
+

{title}

+ {action} +
+ ); +} diff --git a/ui/src/routes/_layout/_admin/admin/system.tsx b/ui/src/routes/_layout/_admin/admin/system.tsx new file mode 100644 index 00000000..5dc49657 --- /dev/null +++ b/ui/src/routes/_layout/_admin/admin/system.tsx @@ -0,0 +1,68 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { getAccount, getActiveRuntime, getAppName, getRepository } from "@/app"; +import { Card } from "@/components"; +import { InfoRow } from "@/components/ui/info-row"; + +export const Route = createFileRoute("/_layout/_admin/admin/system")({ + loader: async ({ context }) => ({ + runtimeConfig: context.runtimeConfig, + }), + head: () => ({ + meta: [{ title: "Admin System | app" }], + }), + component: AdminSystem, +}); + +function AdminSystem() { + const { runtimeConfig } = Route.useLoaderData(); + const account = getAccount(runtimeConfig); + const appName = getAppName(runtimeConfig); + const repository = getRepository(runtimeConfig); + const runtime = getActiveRuntime(runtimeConfig); + + const env = runtimeConfig?.env; + const networkId = runtimeConfig?.networkId; + const hostUrl = runtimeConfig?.hostUrl; + const apiBase = runtimeConfig?.apiBase; + const rpcBase = runtimeConfig?.rpcBase; + const assetsUrl = runtimeConfig?.assetsUrl; + const runtimeBasePath = runtime?.runtimeBasePath; + + return ( +
+
+
+

System

+

+ Runtime configuration for this deployment. +

+
+
+ +
+ +

Runtime

+ + + + +
+ + +

Deployment

+ + + + +
+ + +

Endpoints

+ + + +
+
+
+ ); +} diff --git a/ui/src/routes/_layout/_anon.tsx b/ui/src/routes/_layout/_anon.tsx new file mode 100644 index 00000000..2a14987a --- /dev/null +++ b/ui/src/routes/_layout/_anon.tsx @@ -0,0 +1,56 @@ +import { createFileRoute, Link, Outlet, redirect } from "@tanstack/react-router"; +import { getAppName, sessionQueryOptions } from "@/app"; +import { ThemeToggle } from "@/components/theme-toggle"; + +export const Route = createFileRoute("/_layout/_anon")({ + beforeLoad: async ({ context }) => { + const { queryClient, authClient } = context; + const initialSession = context.session; + const session = + initialSession ?? + queryClient.getQueryData(sessionQueryOptions(authClient, initialSession).queryKey); + if (session?.user) { + throw redirect({ to: "/dashboard", search: {} }); + } + }, + component: AnonLayout, +}); + +function AnonLayout() { + const { runtimeConfig } = Route.useRouteContext(); + const appName = getAppName(runtimeConfig); + + return ( +
+
+
+ + + {appName} + + + + +
+ +
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/ui/src/routes/_layout/login.tsx b/ui/src/routes/_layout/_anon/login.tsx similarity index 85% rename from ui/src/routes/_layout/login.tsx rename to ui/src/routes/_layout/_anon/login.tsx index 96284716..fb37f0ee 100644 --- a/ui/src/routes/_layout/login.tsx +++ b/ui/src/routes/_layout/_anon/login.tsx @@ -3,18 +3,15 @@ import { createFileRoute, Navigate, redirect, useNavigate } from "@tanstack/reac import { useEffect, useState } from "react"; import { toast } from "sonner"; import { getAppName, sessionQueryOptions, useAuthClient } from "@/app"; -import builtOn from "@/assets/built_on.png"; -import builtOnRev from "@/assets/built_on_rev.png"; import { BrandElement } from "@/components/brand-element"; import { Button } from "@/components/ui/button"; -import { NetworkToggle } from "@/components/ui/network-toggle"; import { UnderConstruction } from "@/components/under-construction"; type SearchParams = { redirect?: string; }; -export const Route = createFileRoute("/_layout/login")({ +export const Route = createFileRoute("/_layout/_anon/login")({ ssr: false, validateSearch: (search: Record): SearchParams => ({ redirect: typeof search.redirect === "string" ? search.redirect : undefined, @@ -27,7 +24,7 @@ export const Route = createFileRoute("/_layout/login")({ queryClient.getQueryData(sessionQueryOptions(authClient, initialSession).queryKey); if (session?.user) { - const redirectTo = search.redirect?.startsWith("/") ? search.redirect : "/home"; + const redirectTo = search.redirect?.startsWith("/") ? search.redirect : "/dashboard"; throw redirect({ to: redirectTo, search: {} }); } }, @@ -60,7 +57,7 @@ function LoginPage() { }, [auth.near]); const handleSuccess = async (message: string) => { - const redirectTo = redirect?.startsWith("/") ? redirect : "/home"; + const redirectTo = redirect?.startsWith("/") ? redirect : "/dashboard"; toast.success(message); queryClient.invalidateQueries({ queryKey: ["session"] }); navigate({ to: redirectTo, replace: true, search: {} }); @@ -113,15 +110,14 @@ function LoginPage() { }; if (session?.user) { - const redirectTo = redirect?.startsWith("/") ? redirect : "/home"; + const redirectTo = redirect?.startsWith("/") ? redirect : "/dashboard"; return ; } const isPending = nearPending || anonPending; return ( -
- +
@@ -142,7 +138,25 @@ function LoginPage() {
- -
); } diff --git a/ui/src/routes/_layout/_authenticated.tsx b/ui/src/routes/_layout/_authenticated.tsx index f6c8d6de..83bfd82d 100644 --- a/ui/src/routes/_layout/_authenticated.tsx +++ b/ui/src/routes/_layout/_authenticated.tsx @@ -1,5 +1,14 @@ -import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; -import { type SessionData, sessionQueryOptions } from "@/app"; +import { createFileRoute, Link, Outlet, redirect, useRouterState } from "@tanstack/react-router"; +import { ClipboardList, Compass, Globe, Home, Menu, Shield } from "lucide-react"; +import { useState } from "react"; +import type { SessionData } from "@/app"; +import { getAccount, getActiveRuntime, getAppName, sessionQueryOptions } from "@/app"; +import { NearBranding } from "@/components/near-branding"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { UserNav } from "@/components/user-nav"; +import { cn } from "@/lib/utils"; interface AuthContext { isAuthenticated: boolean; @@ -11,6 +20,30 @@ interface AuthContext { isBanned: boolean; } +type SidebarRole = "anon" | "member" | "admin"; + +interface SidebarItem { + icon: React.ComponentType<{ className?: string }>; + label: string; + to: string; + roleRequired: SidebarRole; +} + +function filterSidebarByRole(items: SidebarItem[], userRole: SidebarRole): SidebarItem[] { + return items.filter((item) => { + if (item.roleRequired === "anon") return true; + if (item.roleRequired === "member" && userRole !== "anon") return true; + if (item.roleRequired === "admin" && userRole === "admin") return true; + return false; + }); +} + +function getUserRole(isAuthenticated: boolean, isAdmin: boolean): SidebarRole { + if (isAdmin) return "admin"; + if (isAuthenticated) return "member"; + return "anon"; +} + export const Route = createFileRoute("/_layout/_authenticated")({ beforeLoad: async ({ context, location }) => { const { queryClient, authClient } = context; @@ -53,9 +86,218 @@ export const Route = createFileRoute("/_layout/_authenticated")({ }); function AuthenticatedLayout() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const { runtimeConfig, session } = Route.useRouteContext(); + const appName = getAppName(runtimeConfig); + const runtime = getActiveRuntime(runtimeConfig); + const account = getAccount(runtimeConfig); + const isAdmin = session?.user?.role === "admin"; + + const sidebarItems: SidebarItem[] = [ + { icon: Home, label: "dashboard", to: "/dashboard", roleRequired: "anon" }, + { icon: Shield, label: "admin", to: "/admin", roleRequired: "admin" }, + ]; + const visibleItems = filterSidebarByRole(sidebarItems, getUserRole(true, isAdmin)); + + const isActive = (item: SidebarItem) => { + return pathname === item.to || (item.to !== "/" && pathname.startsWith(`${item.to}/`)); + }; + return ( -
- +
+ + +
+
+
+
+ + + {appName} + + + + +
+ {runtime?.accountId ?? account} + / + + {pathname === "/" ? "home" : pathname.slice(1).split("/").join(" / ")} + +
+
+ +
+ +
+
+
+ +
+
+ +
+
+
+ +
); } + +function MobileTabBar({ + visibleItems, + isActive, +}: { + visibleItems: SidebarItem[]; + isActive: (item: SidebarItem) => boolean; +}) { + const [drawerOpen, setDrawerOpen] = useState(false); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const tabActive = (to: string) => (to === "/" ? pathname === "/" : pathname.startsWith(to)); + + return ( + + ); +} + +function TabItem({ + to, + icon: Icon, + label, + active, +}: { + to: string; + icon: React.ComponentType<{ className?: string }>; + label: string; + active: boolean; +}) { + return ( + + + {label} + + ); +} diff --git a/ui/src/routes/_layout/_authenticated/home.tsx b/ui/src/routes/_layout/_authenticated/dashboard.tsx similarity index 92% rename from ui/src/routes/_layout/_authenticated/home.tsx rename to ui/src/routes/_layout/_authenticated/dashboard.tsx index e28c6796..86f23a6c 100644 --- a/ui/src/routes/_layout/_authenticated/home.tsx +++ b/ui/src/routes/_layout/_authenticated/dashboard.tsx @@ -2,12 +2,29 @@ import { useQuery } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { Home as HomeIcon, Settings } from "lucide-react"; import { useMemo } from "react"; -import { type Passkey, type SessionData, sessionQueryOptions, useAuthClient } from "@/app"; +import { + getAccount, + type Passkey, + type SessionData, + sessionQueryOptions, + useAuthClient, +} from "@/app"; import { Card } from "@/components"; import { PageContainer } from "@/components/layout/page-container"; import { InfoRow } from "@/components/ui/info-row"; -export const Route = createFileRoute("/_layout/_authenticated/home")({ +export const Route = createFileRoute("/_layout/_authenticated/dashboard")({ + beforeLoad: async ({ context }) => { + const { apiClient, runtimeConfig } = context; + const accountId = getAccount(runtimeConfig); + let tenant: Awaited> | null = null; + try { + tenant = await apiClient.resolveTenant({ accountId }); + } catch { + tenant = null; + } + return { tenant }; + }, head: () => ({ meta: [{ title: "Workspace | app" }, { name: "description", content: "Your workspace." }], }), diff --git a/ui/src/routes/_layout/_authenticated/organizations/$slug.tsx b/ui/src/routes/_layout/_authenticated/orgs/$slug.tsx similarity index 99% rename from ui/src/routes/_layout/_authenticated/organizations/$slug.tsx rename to ui/src/routes/_layout/_authenticated/orgs/$slug.tsx index e95cc943..c26288a3 100644 --- a/ui/src/routes/_layout/_authenticated/organizations/$slug.tsx +++ b/ui/src/routes/_layout/_authenticated/orgs/$slug.tsx @@ -59,7 +59,7 @@ const orgMembersQueryKey = (orgId: string) => ["org-members", orgId] as const; const orgInvitationsQueryKey = (orgId: string) => ["org-invitations", orgId] as const; const orgApiKeysQueryKey = (orgId: string) => ["org-api-keys", orgId] as const; -export const Route = createFileRoute("/_layout/_authenticated/organizations/$slug")({ +export const Route = createFileRoute("/_layout/_authenticated/orgs/$slug")({ head: () => ({ title: "Organization | auth.everything.dev", meta: [{ name: "description", content: "Manage organization details and members." }], @@ -328,7 +328,7 @@ function OrganizationDetail() { onSuccess: async () => { toast.success("You have left the organization"); await queryClient.invalidateQueries({ queryKey: ["organizations"] }); - await router.navigate({ to: "/organizations" }); + await router.navigate({ to: "/orgs" }); }, onError: (error: Error) => toast.error(error.message || "Failed to leave organization"), }); @@ -341,7 +341,7 @@ function OrganizationDetail() { onSuccess: async () => { toast.success("Organization deleted"); await queryClient.invalidateQueries({ queryKey: ["organizations"] }); - await router.navigate({ to: "/organizations" }); + await router.navigate({ to: "/orgs" }); }, onError: (error: Error) => toast.error(error.message || "Failed to delete organization"), }); @@ -369,7 +369,7 @@ function OrganizationDetail() { description="This organization does not exist or you do not have access." action={ } /> @@ -383,7 +383,7 @@ function OrganizationDetail() {
- + Organizations / diff --git a/ui/src/routes/_layout/_authenticated/organizations/index.tsx b/ui/src/routes/_layout/_authenticated/orgs/index.tsx similarity index 98% rename from ui/src/routes/_layout/_authenticated/organizations/index.tsx rename to ui/src/routes/_layout/_authenticated/orgs/index.tsx index 96e4c576..ae09675a 100644 --- a/ui/src/routes/_layout/_authenticated/organizations/index.tsx +++ b/ui/src/routes/_layout/_authenticated/orgs/index.tsx @@ -18,7 +18,7 @@ type UserInvitationsResponse = Awaited< >; type UserInvitationItem = NonNullable[number]; -export const Route = createFileRoute("/_layout/_authenticated/organizations/")({ +export const Route = createFileRoute("/_layout/_authenticated/orgs/")({ head: () => ({ title: "Organizations | auth.everything.dev", meta: [{ name: "description", content: "Manage your organizations and teams." }], @@ -114,7 +114,7 @@ function OrganizationsList() { await queryClient.refetchQueries({ queryKey: ["organizations"] }); if (invitation.organizationSlug) { await router.navigate({ - to: "/organizations/$slug", + to: "/orgs/$slug", params: { slug: invitation.organizationSlug }, }); } @@ -164,7 +164,7 @@ function OrganizationsList() {
@@ -253,7 +253,7 @@ function OrganizationsList() {

No organizations yet.

create your first org @@ -303,7 +303,7 @@ function OrganizationsList() {
diff --git a/ui/src/routes/_layout/_authenticated/accept-invitation.$id.tsx b/ui/src/routes/_layout/_authenticated/orgs/invites.$id.tsx similarity index 95% rename from ui/src/routes/_layout/_authenticated/accept-invitation.$id.tsx rename to ui/src/routes/_layout/_authenticated/orgs/invites.$id.tsx index e7941f9a..b1c997e1 100644 --- a/ui/src/routes/_layout/_authenticated/accept-invitation.$id.tsx +++ b/ui/src/routes/_layout/_authenticated/orgs/invites.$id.tsx @@ -5,7 +5,7 @@ import { toast } from "sonner"; import { getAppName, useAuthClient } from "@/app"; import { Badge, Button, Card, CardContent } from "@/components"; -export const Route = createFileRoute("/_layout/_authenticated/accept-invitation/$id")({ +export const Route = createFileRoute("/_layout/_authenticated/orgs/invites/$id")({ head: () => ({ meta: [{ title: `Accept Invitation | ${getAppName()}` }], }), @@ -63,7 +63,7 @@ function AcceptInvitation() { ]); await queryClient.refetchQueries({ queryKey: ["organizations"] }); await router.navigate({ - to: "/organizations/$slug", + to: "/orgs/$slug", params: { slug: invitation?.organizationSlug ?? "" }, }); }, @@ -78,7 +78,7 @@ function AcceptInvitation() { onSuccess: async () => { toast.success("Invitation declined"); await queryClient.invalidateQueries({ queryKey: ["user-invitations"] }); - await router.navigate({ to: "/organizations" }); + await router.navigate({ to: "/orgs" }); }, onError: (error: Error) => toast.error(error.message || "Failed to decline invitation"), }); @@ -100,7 +100,7 @@ function AcceptInvitation() { This invitation does not exist, has expired, or is not addressed to your account.

@@ -166,7 +166,7 @@ function AcceptInvitation() {
diff --git a/ui/src/routes/_layout/_authenticated/organizations/new.tsx b/ui/src/routes/_layout/_authenticated/orgs/new.tsx similarity index 96% rename from ui/src/routes/_layout/_authenticated/organizations/new.tsx rename to ui/src/routes/_layout/_authenticated/orgs/new.tsx index 664b81e7..02ed8509 100644 --- a/ui/src/routes/_layout/_authenticated/organizations/new.tsx +++ b/ui/src/routes/_layout/_authenticated/orgs/new.tsx @@ -7,7 +7,7 @@ import { useAuthClient } from "@/app"; import { Button, Card, CardContent, Input } from "@/components"; import { PageContainer } from "@/components/layout/page-container"; -export const Route = createFileRoute("/_layout/_authenticated/organizations/new")({ +export const Route = createFileRoute("/_layout/_authenticated/orgs/new")({ head: () => ({ title: "New Organization | auth.everything.dev", meta: [{ name: "description", content: "Create a new organization." }], @@ -37,7 +37,7 @@ function NewOrganization() { await queryClient.refetchQueries({ queryKey: ["organizations"] }); if (data?.slug) { await router.navigate({ - to: "/organizations/$slug", + to: "/orgs/$slug", params: { slug: data.slug }, }); } @@ -76,7 +76,7 @@ function NewOrganization() {
@@ -122,7 +122,7 @@ function NewOrganization() {
+ +
+ + +
+ +
+ +
+

Secure authentication

+

+ NEAR wallet sign-in, email, and passkeys via Better Auth — with an authenticated + layout guard and session-aware routing. +

+
+ + +
+ +
+

Organizations

+

+ Create and manage organizations with members, roles, invitations, and API keys — all + backed by typed oRPC endpoints. +

+
+ + +
+ +
+

Typed end to end

+

+ One contract drives the API and the client — schemas, validation, and types stay in + sync across the whole stack. +

+
+
+ + {repository && ( +
+

Fork the template and make it yours.

+ +
+ )} + + + ); +} diff --git a/ui/src/routes/_layout/skill.tsx b/ui/src/routes/_layout/_public/skill.tsx similarity index 98% rename from ui/src/routes/_layout/skill.tsx rename to ui/src/routes/_layout/_public/skill.tsx index db047a71..d2f83c68 100644 --- a/ui/src/routes/_layout/skill.tsx +++ b/ui/src/routes/_layout/_public/skill.tsx @@ -9,7 +9,7 @@ import { Markdown } from "@/components/ui/markdown"; const INTENT_REGISTRY_URL = "https://tanstack.com/intent/registry/everything-dev"; -export const Route = createFileRoute("/_layout/skill")({ +export const Route = createFileRoute("/_layout/_public/skill")({ loader: async ({ context }) => { const runtimeConfig = context.runtimeConfig; diff --git a/ui/src/routes/_layout/things/$thingId.tsx b/ui/src/routes/_layout/_public/things/$thingId.tsx similarity index 98% rename from ui/src/routes/_layout/things/$thingId.tsx rename to ui/src/routes/_layout/_public/things/$thingId.tsx index 0fdb7405..ac3184f4 100644 --- a/ui/src/routes/_layout/things/$thingId.tsx +++ b/ui/src/routes/_layout/_public/things/$thingId.tsx @@ -6,7 +6,7 @@ import { sessionQueryOptions, useApiClient, useAuthClient } from "@/app"; import { Badge, Button } from "@/components"; import { Skeleton } from "@/components/ui/skeleton"; -export const Route = createFileRoute("/_layout/things/$thingId")({ +export const Route = createFileRoute("/_layout/_public/things/$thingId")({ head: ({ params }) => ({ meta: [ { title: `${params.thingId} | Things | everything.dev` }, diff --git a/ui/src/routes/_layout/things/index.tsx b/ui/src/routes/_layout/_public/things/index.tsx similarity index 97% rename from ui/src/routes/_layout/things/index.tsx rename to ui/src/routes/_layout/_public/things/index.tsx index 6cbee7e0..fba26c37 100644 --- a/ui/src/routes/_layout/things/index.tsx +++ b/ui/src/routes/_layout/_public/things/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; -export const Route = createFileRoute("/_layout/things/")({ +export const Route = createFileRoute("/_layout/_public/things/")({ head: () => ({ meta: [ { title: "Things | everything.dev" }, diff --git a/ui/src/routes/_layout/things/live.tsx b/ui/src/routes/_layout/_public/things/live.tsx similarity index 98% rename from ui/src/routes/_layout/things/live.tsx rename to ui/src/routes/_layout/_public/things/live.tsx index 7be8f0c6..674d7813 100644 --- a/ui/src/routes/_layout/things/live.tsx +++ b/ui/src/routes/_layout/_public/things/live.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useApiClient } from "@/app"; import { Badge } from "@/components"; -export const Route = createFileRoute("/_layout/things/live")({ +export const Route = createFileRoute("/_layout/_public/things/live")({ head: () => ({ meta: [ { title: "Live Stream | Things | everything.dev" }, diff --git a/ui/src/routes/_layout/index.tsx b/ui/src/routes/_layout/index.tsx deleted file mode 100644 index f998f7f7..00000000 --- a/ui/src/routes/_layout/index.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { createFileRoute, Link, redirect } from "@tanstack/react-router"; -import { Building2, Plus, Shield } from "lucide-react"; -import { sessionQueryOptions, useApiClient, useAuthClient } from "@/app"; -import { Button, Card } from "@/components"; -import { PageContainer } from "@/components/layout/page-container"; - -export const Route = createFileRoute("/_layout/")({ - beforeLoad: async ({ context }) => { - const { authClient } = context; - const session = await context.queryClient.ensureQueryData( - sessionQueryOptions(authClient, context.session), - ); - if (!session?.user) { - throw redirect({ to: "/login" }); - } - }, - component: TenantListPage, -}); - -function TenantListPage() { - const apiClient = useApiClient(); - const auth = useAuthClient(); - const { data: session } = useQuery(sessionQueryOptions(auth, undefined)); - - const { data: tenants = [], isLoading } = useQuery({ - queryKey: ["tenants"], - queryFn: () => apiClient.listTenants(), - staleTime: 30_000, - }); - - return ( - -
-
-
- - Tenants -
-
-
-

- {session?.user?.name || session?.user?.email || "Your"} Tenants -

-
- -
-
- - {isLoading ? ( -
- {[1, 2, 3].map((n) => ( - -
-
-
- - ))} -
- ) : tenants.length === 0 ? ( - - -

No tenants yet.

-

- Create a tenant to deploy your own app with custom UI and API. -

- -
- ) : ( -
- {tenants.map((tenant) => ( - -
-
{tenant.name}
-
- {tenant.subdomain} -
-
-
- - -
-
- -
-
- ))} -
- )} -
- - ); -} - -function TenantMeta({ label, value, mono }: { label: string; value: string; mono?: boolean }) { - return ( -
- {label} - - {value} - -
- ); -} diff --git a/ui/src/styles.css b/ui/src/styles.css index 17e0fd36..4da787d1 100644 --- a/ui/src/styles.css +++ b/ui/src/styles.css @@ -202,34 +202,6 @@ code { } } -@keyframes fade-in-up { - from { - opacity: 0; - transform: translateY(8px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.animate-fade-in-up { - animation: fade-in-up 0.4s cubic-bezier(0.4, 0, 0.2, 1) both; -} - -@keyframes fade-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -.animate-fade-in { - animation: fade-in 0.6s cubic-bezier(0.4, 0, 0.2, 1) both; -} - @keyframes subtitle-cycle { 0% { opacity: 0; } 15% { opacity: 1; }