Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,47 @@ Anything the frontend imports from a backend feature must be a leaf module —
pure types and functions, no Worker-only imports — or it lands in the client
bundle.

# Running the app

Onshape launches the app at `/init`, which needs a real Onshape session and
https. Standalone mode is the same SPA without either, and is how to drive the
app locally — including headless. Don't build a stub server for the API; run the
real one.

Put this in `.env` (git-ignored). It is the whole set needed to get a signed-in
admin; the OAuth keys in the README are only for talking to Onshape itself:

```
FORCE_SIGNED_IN=true # a fake user, so no OAuth round trip
VITE_ACCESS_LEVEL_OVERRIDE=admin # granted by the server, and viewed by the client
```

Then `npm run dev` (applies local D1 migrations, then serves
http://localhost:3000). The dev server goes https only when `localhost-key.pem`
and `localhost.pem` are present, so leave them out for a headless browser.

The test Worker ignores `.env` (`vitest.config.ts` turns that off), so leaving
one in place does not rewrite what the auth tests assert.

Where to point it:

- `/` — redirects to the last library used, from `localStorage`.
- `/app/library/<library-id>` — a library; ids are in `library-id.ts`.
- `/app/library/<library-id>/groups/<group-id>` — one group.

Insert and derive key off a full element path in the search params, which is
what `useIsConnectedToOnshape` tests, so standalone hides them. Append what
Onshape would send to exercise that UI:
`?elementType=PARTSTUDIO&documentId=…&instanceType=w&instanceId=…&elementId=…`

Local D1 starts empty, so a library renders "No groups found". Import a cert
dump rather than reloading from Onshape, which spends the account's API
allocation:

```
npx wrangler d1 execute DB --local --file=<cert-dump>.sql
```

# Cloudflare Workers

STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Always retrieve current documentation before any Workers, KV, R2, D1, Durable Objects, Queues, Vectorize, AI, or Agents SDK task.
Expand Down
8 changes: 3 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,9 @@ OAUTH_CLIENT_ID=<Your OAuth client id>
OAUTH_CLIENT_SECRET=<Your OAuth client secret>
SESSION_SECRET=gNSzdRbs4dJYz0obHfeRwaD+u5QbZgJx+V8/rgUH6AiOdoppP3wjeaM97nZmxeJa

# One of admin, editor, or user. Sets the max access level granted. Does nothing in production.
ACCESS_LEVEL_OVERRIDE=admin

# One of admin, editor, or user. The level the app is viewed as by default (client-side).
VITE_DEFAULT_ACCESS_LEVEL=admin
# One of admin, editor, or user. Granted by the server and viewed by the client,
# so both sides agree. Ignored in production.
VITE_ACCESS_LEVEL_OVERRIDE=admin

# Signs you in as a fake user, so signed-in UI can be tested without an Onshape
# session. Onshape calls it reveals won't work, so leave it unset normally.
Expand Down
45 changes: 45 additions & 0 deletions src/backend/features/auth/caller.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { env } from "cloudflare:workers";
import { env as processEnv } from "process";
import { afterEach, describe, expect, it } from "vitest";
import { AccessLevel } from "./access-level";
import { productionCaller } from "./caller";
import { createApp } from "../../app";
import { jsonRequest } from "../../../__test_utils__";

const app = createApp(productionCaller);

/** What the real caller resolves for a request carrying no Onshape session. */
async function getMaxAccessLevel(override?: AccessLevel): Promise<AccessLevel> {
const res = await app.request("/api/access-data", jsonRequest("GET"), {
...env,
VITE_ACCESS_LEVEL_OVERRIDE: override
});
const body: { maxAccessLevel: AccessLevel } = await res.json();
return body.maxAccessLevel;
}

describe("the dev access-level override", () => {
const nodeEnv = processEnv.NODE_ENV;
afterEach(() => {
processEnv.NODE_ENV = nodeEnv;
});

it("grants the level it names", async () => {
expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe(
AccessLevel.ADMIN
);
});

// It is the one thing standing between a stray env var and admin, so it
// must not survive a production build.
it("is ignored in production", async () => {
processEnv.NODE_ENV = "production";
expect(await getMaxAccessLevel(AccessLevel.ADMIN)).toBe(
AccessLevel.USER
);
});

it("leaves an unset override to the caller's own session", async () => {
expect(await getMaxAccessLevel()).toBe(AccessLevel.USER);
});
});
20 changes: 16 additions & 4 deletions src/backend/features/auth/caller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,21 @@ export async function isAuthenticated(c: AppContext): Promise<boolean> {
}
}

/** FORCE_SIGNED_IN is a dev-only escape hatch, ignored in production. */
/** The dev-only escape hatches are ignored in production. */
function isDevelopment(): boolean {
return processEnv.NODE_ENV !== "production";
}

export function isForceSignedIn(c: AppContext): boolean {
return !!c.env.FORCE_SIGNED_IN && processEnv.NODE_ENV !== "production";
return !!c.env.FORCE_SIGNED_IN && isDevelopment();
}

/** The access level granted without asking Onshape, in dev only. */
function getAccessLevelOverride(c: AppContext): AccessLevel | undefined {
if (!isDevelopment()) {
return undefined;
}
return c.env.VITE_ACCESS_LEVEL_OVERRIDE as AccessLevel | undefined;
}

/**
Expand Down Expand Up @@ -157,8 +169,8 @@ export const productionCaller: CallerFactory = (c) => ({
return getCachedUserId(c);
},
getAccessLevel: async () => {
const override = c.env.ACCESS_LEVEL_OVERRIDE;
if (override) return override as AccessLevel;
const override = getAccessLevelOverride(c);
if (override) return override;
// getCachedAccessLevel needs a real Onshape session, so only call it
// for a genuinely signed-in caller (not FORCE_SIGNED_IN).
if (!isForceSignedIn(c) && (await isSignedIn(c))) {
Expand Down
2 changes: 1 addition & 1 deletion src/backend/features/auth/guards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ describe("requireEditorMiddleware", () => {
});

// Access level alone would admit a signed-out caller wherever it is
// granted without a session, e.g. behind a dev ACCESS_LEVEL_OVERRIDE.
// granted without a session, e.g. behind a dev access-level override.
it("401s an editor-level caller who is not signed in", async () => {
const app = createTestApp({
signedIn: false,
Expand Down
2 changes: 1 addition & 1 deletion src/backend/features/auth/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const requireSignInMiddleware: MiddlewareHandler<AppContextEnv> = async (

/**
* Editing implies a session: access level alone would admit a signed-out caller
* under a dev `ACCESS_LEVEL_OVERRIDE`, and answer 403 rather than 401 otherwise.
* under a dev access-level override, and answer 403 rather than 401 otherwise.
*/
export const requireEditorMiddleware: MiddlewareHandler<AppContextEnv> = async (
c,
Expand Down
46 changes: 46 additions & 0 deletions src/backend/features/configurations/combinations.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
AUTO_INDEX_THRESHOLD,
countCombinations,
countConfigurations,
enumerateConfigurations,
IndexingBand,
Expand Down Expand Up @@ -178,6 +179,51 @@ describe("countConfigurations", () => {
});
});

describe("countCombinations", () => {
it("counts an insertable with nothing to vary as having none", () => {
expect(countCombinations([])).toBe(0);
expect(
countCombinations([
enumParam("A", ["x", "y"], { isCosmetic: true })
])
).toBe(0);
});

it("agrees with countConfigurations under the index cap", () => {
for (const configs of [2, 7, AUTO_INDEX_THRESHOLD, 500]) {
const params = paramsWithConfigs(configs);
expect(countCombinations(params)).toBe(
countConfigurations(params).count
);
}
});

it("counts on past the index cap, which countConfigurations stops at", () => {
const params = paramsWithConfigs(MAX_PART_NUMBER_CONFIGURATIONS * 4);
expect(countConfigurations(params).count).toBeNull();
expect(countCombinations(params)).toBe(
MAX_PART_NUMBER_CONFIGURATIONS * 4
);
});

it("skips values a visibility condition hides, as enumeration does", () => {
const params: ConfigurationParameter[] = [
enumParam("A", ["a1", "a2"]),
{
...enumParam("B", ["b1", "b2", "b3"]),
condition: equals("A", "a1")
}
];
expect(countCombinations(params)).toBe(
enumerateConfigurations(params).configurations.length
);
});

it("gives up past its own cap rather than counting forever", () => {
expect(countCombinations(paramsWithConfigs(64), 32)).toBeNull();
});
});

describe("isIndexingEnabled", () => {
it.each([
// Under the threshold everything indexes, custom included: a part with
Expand Down
60 changes: 60 additions & 0 deletions src/backend/features/configurations/combinations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,66 @@ export function isIndexedParameter(
return !parameter.isCosmetic;
}

/** The most combinations counted for display, far past the index cap on work. */
export const MAX_COUNTED_CONFIGURATIONS = 100_000;

/** The true count, which runs past the index cap so the admin card can show it. */
export function countCombinations(
parameters: ConfigurationParameter[],
cap: number = MAX_COUNTED_CONFIGURATIONS
): number | null {
// Depth-first: only the count is wanted, so one path is held rather than all.
const indexed = parameters.filter(isIndexedParameter);
let count = 0;
let capped = false;

const walk = (depth: number, configuration: ParameterValues) => {
if (depth === indexed.length) {
// The lone empty default is not a configuration of its own.
if (Object.keys(configuration).length > 0) {
count++;
capped = count > cap;
}
return;
}
const parameter = indexed[depth];
const values = evaluateCondition(
parameter.condition,
configuration,
parameters
)
? parameterValues(parameter, configuration, parameters)
: [];
// Hidden here, or with nothing to pick: left unset for Onshape to default.
if (values.length === 0) {
walk(depth + 1, configuration);
return;
}
for (const value of values) {
walk(depth + 1, { ...configuration, [parameter.id]: value });
if (capped) {
return;
}
}
};

walk(0, {});
return capped ? null : count;
}

function parameterValues(
parameter: EnumParameter | BooleanParameter,
configuration: ParameterValues,
parameters: ConfigurationParameter[]
): string[] {
if (parameter.type === ParameterType.BOOLEAN) {
return ["true", "false"];
}
return getVisibleOptions(parameter, configuration, parameters).map(
(option) => option.id
);
}

export interface EnumerateResult {
/** The enumerated configurations, or empty when `capped`. */
configurations: ParameterValues[];
Expand Down
3 changes: 2 additions & 1 deletion src/backend/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export interface AppBindings {
/** Renders a configuration's thumbnails outside a request; see ThumbnailWorkflow. */
THUMBNAIL_WORKFLOW: Workflow<ThumbnailWorkflowParams>;
ADMIN_TEAM: string;
ACCESS_LEVEL_OVERRIDE?: string;
/** Dev-only: the access level granted, bypassing Onshape. */
VITE_ACCESS_LEVEL_OVERRIDE?: string;
/** Testing-only: treat requests as signed in with a fake user. Not for production. */
FORCE_SIGNED_IN?: string;
}
Expand Down
4 changes: 2 additions & 2 deletions src/frontend/features/auth/access-level.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ const DEFAULT_ACCESS_DATA: AccessData = {
signedIn: false
};

/** The level the app is viewed as by default; overridable in dev via a Vite var. */
/** The level the app is viewed as by default; the dev override grants it too. */
const DEFAULT_ACCESS_LEVEL =
(import.meta.env.VITE_DEFAULT_ACCESS_LEVEL as AccessLevel | undefined) ??
(import.meta.env.VITE_ACCESS_LEVEL_OVERRIDE as AccessLevel | undefined) ??
AccessLevel.USER;

export function getAccessDataQuery() {
Expand Down
Loading
Loading