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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ with `packageManager`.
- LLM code (Effect AI + `@yielded/agent`) is only in `apps/ai`; Maple-specific wiring lives in
`apps/ai/src/platform/Llm.ts`.
- Shared services live in `packages/backend`, imported via `@maple/backend/*` subpaths.
- Analysts (`org:analyst`) read everything and build dashboards. The auth middlewares refuse them every
other non-GET endpoint unless it is marked `AnalystAccess` (`packages/domain/src/http/analyst-access.ts`):
`read` for a POST that only queries, `personal` for the caller's own state, `author` for dashboards. An
unmarked read-only POST is a 403 for them.

### Service conventions

Expand Down
35 changes: 33 additions & 2 deletions apps/api/src/routes/v2/dashboards.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,11 +171,18 @@ const makeHarness = () => {
const ORG = Schema.decodeSync(OrgId)("org_dashboard_e2e")
const USER = Schema.decodeSync(UserId)("user_dashboard_e2e")

const bootstrapKey = (scopes?: ReadonlyArray<string>) =>
// `roles` makes it a key minted for a person, which acts with that person's org roles.
const bootstrapKey = (scopes?: ReadonlyArray<string>, roles?: ReadonlyArray<string>) =>
runtime.runPromise(
Effect.gen(function* () {
const service = yield* ApiKeysService
return yield* service.create(ORG, USER, { name: "dashboard-test", scopes })
return yield* service.create(ORG, USER, {
name: "dashboard-test",
scopes,
...(roles === undefined
? undefined
: { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }),
})
}),
)

Expand Down Expand Up @@ -499,6 +506,30 @@ describe("v2 dashboards over HTTP", () => {
await harness.dispose()
})

it("lets an analyst build, publish and delete a dashboard", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey(undefined, ["org:analyst"])

const created = await harness.request("POST", "/v2/dashboards", key.secret, {
name: "Signup funnel",
widgets: [],
})
expect(created.status).toBe(200)
const path = `/v2/dashboards/${created.body.id}`

const renamed = await harness.request("PATCH", path, key.secret, { name: "Signup funnel, weekly" })
expect(renamed.status).toBe(200)
expect(renamed.body.name).toBe("Signup funnel, weekly")

const shared = await harness.request("PUT", `${path}/share`, key.secret, { mode: "public" })
expect(shared.status).toBe(200)
expect(typeof shared.body.token).toBe("string")

expect((await harness.request("DELETE", path, key.secret)).status).toBe(200)
expect((await harness.request("GET", path, key.secret)).status).toBe(404)
await harness.dispose()
})

// The picker asks for every template in one page; `limit` defaulting to 20
// silently truncated the catalogue and hid the last two templates.
it("returns the whole template catalogue with structured requirements", async () => {
Expand Down
30 changes: 28 additions & 2 deletions apps/api/src/routes/v2/mobile-devices.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,17 @@ const makeHarness = () => {
return { status: response.status, body: text.length === 0 ? null : JSON.parse(text) }
}

const bootstrapKey = (scopes?: ReadonlyArray<string>) =>
const bootstrapKey = (scopes?: ReadonlyArray<string>, roles?: ReadonlyArray<string>) =>
runtime.runPromise(
Effect.gen(function* () {
const service = yield* ApiKeysService
return yield* service.create(ORG, USER, { name: "mobile-test", scopes })
return yield* service.create(ORG, USER, {
name: "mobile-test",
scopes,
...(roles === undefined
? undefined
: { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }),
})
}),
)

Expand Down Expand Up @@ -184,6 +190,26 @@ describe("v2 mobile devices", () => {
}
})

it("lets an analyst register and remove their own device", async () => {
const harness = makeHarness()
try {
const key = await harness.bootstrapKey(undefined, ["org:analyst"])
const created = await harness.request("PUT", `/v2/mobile_devices/${TOKEN}`, key.secret, {
platform: "ios",
environment: "sandbox",
bundle_id: "com.maple.mobile",
})
expect(created.status).toBe(200)
expect(created.body.token).toBe(TOKEN)

const removed = await harness.request("DELETE", `/v2/mobile_devices/${TOKEN}`, key.secret)
expect(removed.status).toBe(200)
expect(removed.body.deleted).toBe(true)
} finally {
await harness.dispose()
}
})

it("records and forgets a Live Activity's update token", async () => {
const harness = makeHarness()
try {
Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/routes/v2/phase1-resources.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,13 +726,16 @@ const makeHarness = (
}
}

const bootstrapKey = (scopes?: ReadonlyArray<string>) =>
const bootstrapKey = (scopes?: ReadonlyArray<string>, roles?: ReadonlyArray<string>) =>
runtime.runPromise(
Effect.gen(function* () {
const service = yield* ApiKeysService
return yield* service.create(ORG, USER, {
name: "phase1-test",
scopes,
...(roles === undefined
? undefined
: { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }),
})
}),
)
Expand Down Expand Up @@ -899,6 +902,24 @@ describe("v2 error_issues over HTTP", () => {
await harness.dispose()
})

it("lets an analyst read an issue and refuses to let them triage it", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey(undefined, ["org:analyst"])

const read = await harness.request("GET", `/v2/error_issues/${ISS_ID}`, { token: key.secret })
expect(read.status).toBe(200)

const refused = await harness.request("POST", `/v2/error_issues/${ISS_ID}/transitions`, {
token: key.secret,
body: { to_state: "todo", expected_state: "triage" },
})
expect(refused.status).toBe(403)
expect(refused.body.error.code).toBe("insufficient_permissions")
expect(refused.body.error.message).toContain("Analysts can read Maple")
expect(harness.issueActions()).toEqual([])
await harness.dispose()
})

it("maps refused transitions to 409, missing issues to 404, and needs write scope", async () => {
const harness = makeHarness()
const reader = await harness.bootstrapKey(["error_issues:read"])
Expand Down
21 changes: 19 additions & 2 deletions apps/api/src/routes/v2/telemetry.http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,11 +378,17 @@ const makeHarness = (
const runtime = ManagedRuntime.make(servicesLive)
const org = Schema.decodeSync(OrgId)("org_telemetry_e2e")
const user = Schema.decodeSync(UserId)("user_telemetry_e2e")
const bootstrapKey = (scopes?: ReadonlyArray<string>) =>
const bootstrapKey = (scopes?: ReadonlyArray<string>, roles?: ReadonlyArray<string>) =>
runtime.runPromise(
Effect.gen(function* () {
const service = yield* ApiKeysService
return yield* service.create(org, user, { name: "telemetry-test", scopes })
return yield* service.create(org, user, {
name: "telemetry-test",
scopes,
...(roles === undefined
? undefined
: { metadataJson: { source: "maple_cli", roles, deviceName: "laptop" } }),
})
}),
)
const request = async (method: string, path: string, token: string, body?: unknown) => {
Expand Down Expand Up @@ -565,6 +571,17 @@ describe("v2 telemetry reads over HTTP", () => {
await harness.dispose()
})

it("lets an analyst search traces, a POST that only reads", async () => {
const harness = makeHarness()
const key = await harness.bootstrapKey(undefined, ["org:analyst"])
const searched = await harness.request("POST", "/v2/traces/search", key.secret, {
start_time: START,
end_time: END,
})
expect(searched.status).toBe(200)
expect(searched.body.data[0]).toMatchObject({ object: "trace", id: TRACE_ID })
})

it("requires windows, validates log IDs, and treats telemetry POSTs as scoped reads", async () => {
const harness = makeHarness()
const tracesKey = await harness.bootstrapKey(["traces:read"])
Expand Down
Loading
Loading