@oneteam-app/apps-cli 0.1.0-alpha.0 → 0.1.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/index.js +115 -34
  3. package/package.json +3 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,13 @@
1
+ # @oneteam-app/apps-cli
2
+
3
+ ## 0.1.0-alpha.2
4
+
5
+ - Depends on `@oneteam-app/apps@0.1.0-alpha.2`. The agent instructions it writes link the setup guide on unpkg.
6
+
7
+ ## 0.1.0-alpha.1
8
+
9
+ - Depends on `@oneteam-app/apps@0.1.0-alpha.1`.
10
+
11
+ ## 0.1.0-alpha.0
12
+
13
+ - First alpha.
package/dist/index.js CHANGED
@@ -9,13 +9,13 @@ import { basename, dirname, join, relative, sep } from "path";
9
9
  import { validateManifest } from "@oneteam-app/apps/contracts";
10
10
 
11
11
  // src/generated/instructions.ts
12
- var AGENT_INSTRUCTIONS = "# Oneteam login and authorization\n\nApps built this way are opened from a Oneteam shortcut. Oneteam signs the\nemployee in (no extra password), tells the app who they are, and which of the\napp's own permissions they hold. Admins approve the app and assign its\npermissions to Oneteam roles.\n\n**Supported today:** Next.js App Router (Next 15.2+, 16 recommended), with\n`@oneteam-app/apps`.\n\nThe core runs on any Web-standard runtime (Node, Deno, Supabase edge functions,\nCloudflare Workers), but only the Next.js adapter ships so far. On any other\nstack, stop and tell the user it isn't supported yet.\n\n## Never hand-roll this\n\nWriting the OAuth flow by hand is the main way apps get this wrong: PKCE with\n`plain`, no `state` check, an ID token read with `atob` instead of verified,\ntokens in `localStorage`. Use the package, which does the whole flow in one\ncall, or stop and say it isn't supported on this stack.\n\n## Rules\n\nThese are not style preferences. Breaking one creates a security hole.\n\n- **Deny by default.** `proxy.ts` must run `oneteam.proxy()` for every route.\n Add to `publicPaths` only what anyone on the internet may open (health\n checks, a public landing page), and ask the user first.\n- **Never exclude routes from the proxy by file extension.** `/api/receipts/1.jpg`\n is data. Keep the scaffolded matcher; put public files in `public/assets/`\n and add `'/assets/*'` to `publicPaths`.\n- **The proxy is a safety net, not the check.** Route handlers and server\n actions still call `requirePermission` / `protectRoute` themselves.\n- **Check permissions on the server, every time.** Every server action and\n route handler that reads or changes protected data calls\n `oneteam.requirePermission(...)` (or uses `oneteam.protectRoute`). `<Protected>`\n and `useOneteam().can` only hide UI.\n- **Never trust ids from the browser for authorization.** Load the record\n first, then check against *its* community/owner:\n `requirePermission('expenses.approve', { communityId: expense.communityId })`.\n Scope list queries with `oneteam.communitiesFor(session, key)`.\n- **Identify users by `session.user.id`** (Oneteam user id) or\n `session.user.externalId` (employee number). Not by email, not by `sub`,\n never by a user id sent in a request body.\n- **Secrets stay on the server.** Never prefix `ONETEAM_CLIENT_SECRET` or\n `ONETEAM_SESSION_SECRET` with `NEXT_PUBLIC_`, never log tokens or sessions,\n never pass `getAccessToken()` results to client components.\n- **Don't build your own login.** No custom JWT parsing, no reading tokens from\n query strings, no localStorage tokens, no second login screen.\n- **Community-level permissions are not organisation-wide.**\n `can(session, key)` without `communityId` is only true for organisation-level\n grants. That's intended: don't \"fix\" it by dropping the community check.\n\n## The shape of a protected app\n\n```ts\n// lib/oneteam.ts\nimport { createOneteam } from '@oneteam-app/apps/next';\nimport manifest from '../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// proxy.ts \u2014 deny by default\nexport const proxy = oneteam.proxy({ publicPaths: ['/health'] });\n\n// app/api/oneteam/[...oneteam]/route.ts\nexport const { GET, POST } = oneteam.handlers;\n\n// any server action or route handler that touches protected data\nconst expense = await db.expense.findUniqueOrThrow({ where: { id } });\nawait oneteam.requirePermission('expenses.approve', { communityId: expense.communityId });\n```\n\n## Workflow\n\nDo these in order. Don't skip the checks at the end.\n\n1. **Confirm the stack.** `package.json` has `next` and there is an `app/` or `src/app/` directory.\n2. **Scaffold.**\n ```bash\n npm install @oneteam-app/apps\n npx @oneteam-app/apps-cli init --url https://<production-domain>\n ```\n Ask the user for the production domain if you don't know it. Never guess one,\n and never use a `*.vercel.app` preview URL as the production domain.\n `init` creates `lib/oneteam.ts`, `app/api/oneteam/[...oneteam]/route.ts`,\n `proxy.ts`, `oneteam.app.json` and `.env.example` entries. It won't overwrite\n an existing `middleware.ts`/`proxy.ts`: merge `oneteam.proxy()` into it yourself.\n3. **Declare what the app needs** in `oneteam.app.json`:\n - `scopes`: add `oneteam:organisation` for the employee number,\n `oneteam:communities` / `oneteam:functions` for memberships. Request only\n what the app uses; admins see and approve each one.\n - `permissions`: one entry per action that not every employee may do, e.g.\n `{ \"key\": \"expenses.approve\", \"name\": \"Approve expenses\", \"levels\": [\"organisation\", \"community\"] }`.\n Use `community` level when the action belongs to a store/location/team.\n - Run `npx @oneteam-app/apps-cli validate`.\n4. **Protect the app** following [references/nextjs.md](references/nextjs.md)\n and the rules above.\n5. **Hand over registration to the user.** Registration happens in the Oneteam\n dashboard by an admin: Settings \u2192 Apps & Shortcuts \u2192 Apps \u2192 New app, upload\n `oneteam.app.json`. They get a client id and secret. Tell the user to put\n them in `.env.local` (and in the hosting provider's environment settings)\n themselves. Never ask the user to paste secrets into the chat.\n6. **Verify:** `npx @oneteam-app/apps-cli doctor`, `npm run build`, then walk\n through [references/checklist.md](references/checklist.md) and report the\n result to the user.\n\n## What you cannot do for the user\n\nSay these plainly instead of reporting success:\n\n- Registering the app in Oneteam and getting it approved by an admin.\n- Putting the client id and secret into `.env.local` and the hosting provider.\n- Confirming an admin assigned the app's permissions to roles.\n\nUntil those happen, login will fail with `access_denied` or\n`not_a_oneteam_app_client`, however correct the code is.\n\n## Reference\n\n- [references/nextjs.md](references/nextjs.md): API and patterns for the Next.js adapter\n- [references/checklist.md](references/checklist.md): final security review\n- Example app: `examples/next-app` in the oneteam-apps repository\n- Local testing without Oneteam: `pnpm dev:fake` in that repository";
13
- var CURSOR_RULE = "---\ndescription: Oneteam login and authorization rules for this app\nalwaysApply: true\n---\n\n## Never hand-roll this\n\nWriting the OAuth flow by hand is the main way apps get this wrong: PKCE with\n`plain`, no `state` check, an ID token read with `atob` instead of verified,\ntokens in `localStorage`. Use the package, which does the whole flow in one\ncall, or stop and say it isn't supported on this stack.\n\n## Rules\n\nThese are not style preferences. Breaking one creates a security hole.\n\n- **Deny by default.** `proxy.ts` must run `oneteam.proxy()` for every route.\n Add to `publicPaths` only what anyone on the internet may open (health\n checks, a public landing page), and ask the user first.\n- **Never exclude routes from the proxy by file extension.** `/api/receipts/1.jpg`\n is data. Keep the scaffolded matcher; put public files in `public/assets/`\n and add `'/assets/*'` to `publicPaths`.\n- **The proxy is a safety net, not the check.** Route handlers and server\n actions still call `requirePermission` / `protectRoute` themselves.\n- **Check permissions on the server, every time.** Every server action and\n route handler that reads or changes protected data calls\n `oneteam.requirePermission(...)` (or uses `oneteam.protectRoute`). `<Protected>`\n and `useOneteam().can` only hide UI.\n- **Never trust ids from the browser for authorization.** Load the record\n first, then check against *its* community/owner:\n `requirePermission('expenses.approve', { communityId: expense.communityId })`.\n Scope list queries with `oneteam.communitiesFor(session, key)`.\n- **Identify users by `session.user.id`** (Oneteam user id) or\n `session.user.externalId` (employee number). Not by email, not by `sub`,\n never by a user id sent in a request body.\n- **Secrets stay on the server.** Never prefix `ONETEAM_CLIENT_SECRET` or\n `ONETEAM_SESSION_SECRET` with `NEXT_PUBLIC_`, never log tokens or sessions,\n never pass `getAccessToken()` results to client components.\n- **Don't build your own login.** No custom JWT parsing, no reading tokens from\n query strings, no localStorage tokens, no second login screen.\n- **Community-level permissions are not organisation-wide.**\n `can(session, key)` without `communityId` is only true for organisation-level\n grants. That's intended: don't \"fix\" it by dropping the community check.\n\n## The shape of a protected app\n\n```ts\n// lib/oneteam.ts\nimport { createOneteam } from '@oneteam-app/apps/next';\nimport manifest from '../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// proxy.ts \u2014 deny by default\nexport const proxy = oneteam.proxy({ publicPaths: ['/health'] });\n\n// app/api/oneteam/[...oneteam]/route.ts\nexport const { GET, POST } = oneteam.handlers;\n\n// any server action or route handler that touches protected data\nconst expense = await db.expense.findUniqueOrThrow({ where: { id } });\nawait oneteam.requirePermission('expenses.approve', { communityId: expense.communityId });\n```\n";
12
+ var AGENT_INSTRUCTIONS = "# Oneteam login and authorization\n\nApps built this way are opened from a Oneteam shortcut. Oneteam signs the\nemployee in (no extra password) and tells the app who they are and which\nOneteam permissions they hold. Authorization is RBAC on those permissions:\nadmins grant them through Oneteam roles, and the app checks them by name\n(`network::users.update`). Apps have no permissions or roles of their own.\n\n**Supported today**, with `@oneteam-app/apps`:\n\n- **TanStack Start** (Lovable's default, also on Cloudflare Workers): `@oneteam-app/apps/tanstack-start`\n- **Next.js App Router** (Next 15.2+, 16 recommended): `@oneteam-app/apps/next`\n\nOn any other stack, stop and tell the user it isn't supported yet.\n\n## First: the Oneteam connector\n\nRegistering the app needs the Oneteam MCP connector, which gives you the tools\n`get_app_setup_guide` and `register_app`. If you don't have them, stop and ask\nthe user to add it before anything else. In Lovable:\n\n1. Open **Connectors**, click **+** and choose **MCP server**.\n2. **Server name:** `Oneteam`. **Server URL:** `https://api.oneteam.io/mcp`\n (a Oneteam test environment: its API URL followed by `/mcp`).\n3. Keep **OAuth**, click **Add & authorize** and sign in with Oneteam as an\n organisation admin.\n\nThen call `get_app_setup_guide`. In Claude, Cursor and other MCP clients the\nconnector is added the same way, with the same URL.\n\nNever ask the user for Oneteam secrets before `register_app` has returned them:\nthey don't exist until the app is registered, and registering needs the app's\npublished URL.\n\n## Never hand-roll this\n\nWriting the OAuth flow by hand is the main way apps get this wrong: PKCE with\n`plain`, no `state` check, an ID token read with `atob` instead of verified,\ntokens in `localStorage`. Use the package, which does the whole flow in one\ncall, or stop and say it isn't supported on this stack.\n\n## Rules\n\nThese are not style preferences. Breaking one creates a security hole.\n\n- **Deny by default.** Next.js: `proxy.ts` runs `oneteam.proxy()` for every route.\n TanStack Start: `src/start.ts` has a request middleware that calls\n `oneteam.guard()`. Add to `publicPaths` only what anyone on the internet may\n open (health checks, a public landing page), and ask the user first.\n- **Never exclude routes from the gate by file extension.** `/api/receipts/1.jpg`\n is data. Keep the scaffolded matcher; put public files in `public/assets/`\n and add `'/assets/*'` to `publicPaths`.\n- **The gate is a safety net, not the check.** Server actions, server functions\n and route handlers still call `requirePermission` / `protectRoute` themselves.\n- **Check permissions on the server, every time.** Everything that reads or\n changes protected data runs on the server (Next.js server action or route\n handler, TanStack Start server function or server route) and calls\n `oneteam.requirePermission(...)` (or uses `oneteam.protectRoute`). `<Protected>`\n and `useOneteam().can` only hide UI.\n- **Never trust ids from the browser for authorization.** Load the record\n first, then check against *its* community/owner:\n `requirePermission('network::users.update', { communityId: expense.communityId })`.\n Scope list queries with `oneteam.communitiesFor(session, permission)`.\n- **Identify users by `session.user.id`** (Oneteam user id) or\n `session.user.externalId` (employee number). Not by email, not by `sub`,\n never by a user id sent in a request body.\n- **Secrets stay on the server.** Never prefix `ONETEAM_CLIENT_SECRET` or\n `ONETEAM_SESSION_SECRET` with `NEXT_PUBLIC_` or `VITE_`, never put them in code,\n never log tokens or sessions, never pass `getAccessToken()` results to the browser.\n TanStack Start: import `oneteam.server.ts` only from server functions, server\n routes and `src/start.ts`.\n- **Don't build your own login.** No custom JWT parsing, no reading tokens from\n query strings, no localStorage tokens, no second login screen.\n- **Authorize on Oneteam permissions only.** Not on a role, an admin flag, a\n function name, an email domain or a hard-coded list of user ids. Pick each\n permission from `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`\n (online: https://unpkg.com/@oneteam-app/apps/docs/oneteam-permissions.md)\n (typed as `OneteamPermission`, so a wrong name doesn't compile) and name the\n ones the app uses once, e.g. in `lib/permissions.ts`. Never invent a name or\n match on a prefix.\n- **Community permissions are not organisation-wide.** A `network:` permission\n without `communityId` is only true for users who hold the same action\n organisation-wide. That's intended: don't \"fix\" it by dropping the community check.\n\n## The shape of a protected app\n\nTanStack Start (Lovable): full files in [references/tanstack-start.md](references/tanstack-start.md).\n\n```ts\n// src/lib/oneteam.server.ts\nimport { createOneteam } from '@oneteam-app/apps/tanstack-start';\nimport manifest from '../../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// src/routes/api/oneteam/$.ts\nexport const Route = createFileRoute('/api/oneteam/$')({ server: { handlers: oneteam.handlers } });\n\n// src/start.ts \u2014 deny by default\nconst oneteamMiddleware = createMiddleware().server((ctx) => oneteam.guard(ctx, { publicPaths: ['/health'] }));\nexport const startInstance = createStart(() => ({ requestMiddleware: [oneteamMiddleware] }));\n\n// any server function that touches protected data\nexport const approveExpense = createServerFn({ method: 'POST' })\n .validator((id: string) => id)\n .handler(async ({ data: id }) => {\n const expense = await db.expense.findUniqueOrThrow({ where: { id } });\n await oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n // ...\n });\n```\n\nNext.js:\n\n```ts\n// lib/oneteam.ts\nimport { createOneteam } from '@oneteam-app/apps/next';\nimport manifest from '../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// proxy.ts \u2014 deny by default\nexport const proxy = oneteam.proxy({ publicPaths: ['/health'] });\n\n// app/api/oneteam/[...oneteam]/route.ts\nexport const { GET, POST } = oneteam.handlers;\n\n// lib/permissions.ts \u2014 the Oneteam permissions this app checks\nexport const APPROVE_EXPENSES = 'network::users.update' satisfies OneteamPermission;\n\n// any server action or route handler that touches protected data\nconst expense = await db.expense.findUniqueOrThrow({ where: { id } });\nawait oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n```\n\n## Workflow\n\nDo these in order. Don't skip the checks at the end.\n\n1. **Confirm the stack.** TanStack Start: `package.json` has `@tanstack/react-start`\n and there is a `src/routes/` directory. Next.js: `package.json` has `next` and\n there is an `app/` or `src/app/` directory.\n2. **Scaffold.**\n ```bash\n npm install @oneteam-app/apps\n npx @oneteam-app/apps-cli init --url https://<production-domain>\n ```\n Ask the user for the production domain if you don't know it. Never guess one,\n and never use a preview URL (`*.vercel.app` previews, Lovable's editor preview)\n as the production domain; a published `https://<name>.lovable.app` is fine.\n `init` creates the setup files of [references/tanstack-start.md](references/tanstack-start.md)\n or [references/nextjs.md](references/nextjs.md), `oneteam.app.json` and\n `.env.example` entries. It won't overwrite an existing `src/start.ts`,\n `middleware.ts` or `proxy.ts`: add the gate to it yourself.\n **Without a terminal (Lovable):** add `@oneteam-app/apps` as a dependency and\n create those files yourself, exactly as in the reference.\n3. **Declare what the app needs** in `oneteam.app.json` `scopes`:\n `oneteam:permissions` (scaffolded) for permission checks, `oneteam:communities`\n for memberships and for permissions per community, `oneteam:functions` for\n functions, `oneteam:organisation` for the employee number. Request only what\n the app uses; admins see and approve each one. Then run\n `npx @oneteam-app/apps-cli validate`.\n4. **Choose the permissions.** For each action not every employee may do, pick\n the Oneteam permission whose holders should be allowed, from\n `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`: its \"Granted by\"\n column is the role editor toggle admins use. Use a `network:` permission when\n the action belongs to a store/location/team, an `organisation:` one when it is\n organisation-wide. Tell the user which permissions you chose and why.\n5. **Protect the app** following [references/tanstack-start.md](references/tanstack-start.md)\n or [references/nextjs.md](references/nextjs.md) and the rules above.\n6. **Register the app.** With the Oneteam MCP connector, an organisation admin\n can do it from this chat: call `register_app` with the app's production URL\n (and its localhost URL). Otherwise an admin registers it in the Oneteam\n dashboard. Either way the app starts in development status (only the person\n who registered it can sign in) and comes with a client id and secret. The user\n stores them as server-side secrets themselves (Lovable: project secrets;\n otherwise `.env.local` and the hosting provider's secret settings), together\n with `ONETEAM_SESSION_SECRET`, `ONETEAM_APP_URL` and, when the returned\n `issuer` isn't `https://login.oneteam.io` (a test environment),\n `ONETEAM_ISSUER` and `ONETEAM_API_URL` with the returned values. Never ask\n them to paste the secret into the chat, and never repeat a secret you were shown.\n7. **Verify:** `npx @oneteam-app/apps-cli doctor`, `npm run build`, then walk\n through [references/checklist.md](references/checklist.md) and report the\n result to the user.\n\n## What you cannot do for the user\n\nSay these plainly instead of reporting success:\n\n- Registering the app in Oneteam and getting it approved by an admin.\n- Putting the client id and secret into `.env.local` and the hosting provider.\n- Making sure the right people hold the chosen permissions through their Oneteam roles.\n\nUntil those happen, login will fail with `access_denied` or\n`not_a_oneteam_app_client`, however correct the code is.\n\n## Reference\n\n- [references/tanstack-start.md](references/tanstack-start.md): API and patterns for the TanStack Start adapter (Lovable)\n- [references/nextjs.md](references/nextjs.md): API and patterns for the Next.js adapter\n- [references/checklist.md](references/checklist.md): final security review\n- [references/oneteam-permissions.md](references/oneteam-permissions.md): every Oneteam permission name\n- Example app: `examples/next-app` in the oneteam-apps repository\n- Local testing without Oneteam: `pnpm dev:fake` in that repository";
13
+ var CURSOR_RULE = "---\ndescription: Oneteam login and authorization rules for this app\nalwaysApply: true\n---\n\n## First: the Oneteam connector\n\nRegistering the app needs the Oneteam MCP connector, which gives you the tools\n`get_app_setup_guide` and `register_app`. If you don't have them, stop and ask\nthe user to add it before anything else. In Lovable:\n\n1. Open **Connectors**, click **+** and choose **MCP server**.\n2. **Server name:** `Oneteam`. **Server URL:** `https://api.oneteam.io/mcp`\n (a Oneteam test environment: its API URL followed by `/mcp`).\n3. Keep **OAuth**, click **Add & authorize** and sign in with Oneteam as an\n organisation admin.\n\nThen call `get_app_setup_guide`. In Claude, Cursor and other MCP clients the\nconnector is added the same way, with the same URL.\n\nNever ask the user for Oneteam secrets before `register_app` has returned them:\nthey don't exist until the app is registered, and registering needs the app's\npublished URL.\n\n## Never hand-roll this\n\nWriting the OAuth flow by hand is the main way apps get this wrong: PKCE with\n`plain`, no `state` check, an ID token read with `atob` instead of verified,\ntokens in `localStorage`. Use the package, which does the whole flow in one\ncall, or stop and say it isn't supported on this stack.\n\n## Rules\n\nThese are not style preferences. Breaking one creates a security hole.\n\n- **Deny by default.** Next.js: `proxy.ts` runs `oneteam.proxy()` for every route.\n TanStack Start: `src/start.ts` has a request middleware that calls\n `oneteam.guard()`. Add to `publicPaths` only what anyone on the internet may\n open (health checks, a public landing page), and ask the user first.\n- **Never exclude routes from the gate by file extension.** `/api/receipts/1.jpg`\n is data. Keep the scaffolded matcher; put public files in `public/assets/`\n and add `'/assets/*'` to `publicPaths`.\n- **The gate is a safety net, not the check.** Server actions, server functions\n and route handlers still call `requirePermission` / `protectRoute` themselves.\n- **Check permissions on the server, every time.** Everything that reads or\n changes protected data runs on the server (Next.js server action or route\n handler, TanStack Start server function or server route) and calls\n `oneteam.requirePermission(...)` (or uses `oneteam.protectRoute`). `<Protected>`\n and `useOneteam().can` only hide UI.\n- **Never trust ids from the browser for authorization.** Load the record\n first, then check against *its* community/owner:\n `requirePermission('network::users.update', { communityId: expense.communityId })`.\n Scope list queries with `oneteam.communitiesFor(session, permission)`.\n- **Identify users by `session.user.id`** (Oneteam user id) or\n `session.user.externalId` (employee number). Not by email, not by `sub`,\n never by a user id sent in a request body.\n- **Secrets stay on the server.** Never prefix `ONETEAM_CLIENT_SECRET` or\n `ONETEAM_SESSION_SECRET` with `NEXT_PUBLIC_` or `VITE_`, never put them in code,\n never log tokens or sessions, never pass `getAccessToken()` results to the browser.\n TanStack Start: import `oneteam.server.ts` only from server functions, server\n routes and `src/start.ts`.\n- **Don't build your own login.** No custom JWT parsing, no reading tokens from\n query strings, no localStorage tokens, no second login screen.\n- **Authorize on Oneteam permissions only.** Not on a role, an admin flag, a\n function name, an email domain or a hard-coded list of user ids. Pick each\n permission from `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`\n (online: https://unpkg.com/@oneteam-app/apps/docs/oneteam-permissions.md)\n (typed as `OneteamPermission`, so a wrong name doesn't compile) and name the\n ones the app uses once, e.g. in `lib/permissions.ts`. Never invent a name or\n match on a prefix.\n- **Community permissions are not organisation-wide.** A `network:` permission\n without `communityId` is only true for users who hold the same action\n organisation-wide. That's intended: don't \"fix\" it by dropping the community check.\n\n## The shape of a protected app\n\nTanStack Start (Lovable): full files in [references/tanstack-start.md](references/tanstack-start.md).\n\n```ts\n// src/lib/oneteam.server.ts\nimport { createOneteam } from '@oneteam-app/apps/tanstack-start';\nimport manifest from '../../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// src/routes/api/oneteam/$.ts\nexport const Route = createFileRoute('/api/oneteam/$')({ server: { handlers: oneteam.handlers } });\n\n// src/start.ts \u2014 deny by default\nconst oneteamMiddleware = createMiddleware().server((ctx) => oneteam.guard(ctx, { publicPaths: ['/health'] }));\nexport const startInstance = createStart(() => ({ requestMiddleware: [oneteamMiddleware] }));\n\n// any server function that touches protected data\nexport const approveExpense = createServerFn({ method: 'POST' })\n .validator((id: string) => id)\n .handler(async ({ data: id }) => {\n const expense = await db.expense.findUniqueOrThrow({ where: { id } });\n await oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n // ...\n });\n```\n\nNext.js:\n\n```ts\n// lib/oneteam.ts\nimport { createOneteam } from '@oneteam-app/apps/next';\nimport manifest from '../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// proxy.ts \u2014 deny by default\nexport const proxy = oneteam.proxy({ publicPaths: ['/health'] });\n\n// app/api/oneteam/[...oneteam]/route.ts\nexport const { GET, POST } = oneteam.handlers;\n\n// lib/permissions.ts \u2014 the Oneteam permissions this app checks\nexport const APPROVE_EXPENSES = 'network::users.update' satisfies OneteamPermission;\n\n// any server action or route handler that touches protected data\nconst expense = await db.expense.findUniqueOrThrow({ where: { id } });\nawait oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n```\n";
14
14
 
15
15
  // src/templates.ts
16
16
  var ENV_KEYS = [
17
17
  { key: "ONETEAM_CLIENT_ID", comment: "From Oneteam dashboard \u2192 Settings \u2192 Apps & Shortcuts \u2192 Apps \u2192 your app" },
18
- { key: "ONETEAM_CLIENT_SECRET", comment: "Server only. Never prefix with NEXT_PUBLIC_" },
18
+ { key: "ONETEAM_CLIENT_SECRET", comment: "Server only. Never prefix with NEXT_PUBLIC_ or VITE_" },
19
19
  { key: "ONETEAM_SESSION_SECRET", comment: "At least 32 random characters: openssl rand -base64 32" },
20
20
  { key: "ONETEAM_APP_URL", comment: "Public base URL of this app; must match a registered redirect URI", value: "http://localhost:3000" }
21
21
  ];
@@ -58,14 +58,59 @@ function manifest(name, productionUrl) {
58
58
  name,
59
59
  login_uri: `${base}/api/oneteam/login`,
60
60
  redirect_uris: [`${base}/api/oneteam/callback`],
61
- scopes: ["openid", "profile", "email", "offline_access"],
62
- permissions: [],
61
+ // oneteam:permissions: the user's Oneteam permissions, for can() / requirePermission().
62
+ scopes: ["openid", "profile", "email", "offline_access", "oneteam:permissions"],
63
63
  development: {
64
64
  login_uri: "http://localhost:3000/api/oneteam/login",
65
65
  redirect_uris: ["http://localhost:3000/api/oneteam/callback"]
66
66
  }
67
67
  };
68
68
  }
69
+ function tanstackLibFile(manifestImport) {
70
+ return `import { createOneteam } from '@oneteam-app/apps/tanstack-start';
71
+ import manifest from '${manifestImport}';
72
+
73
+ // Server only (.server.ts): TanStack Start keeps it out of the browser bundle.
74
+ // Request exactly the scopes the manifest declares (and the admin approves).
75
+ export const oneteam = createOneteam({ scopes: manifest.scopes });
76
+ `;
77
+ }
78
+ function tanstackRouteFile() {
79
+ return `import { createFileRoute } from '@tanstack/react-router';
80
+ import { oneteam } from '../../../lib/oneteam.server';
81
+
82
+ // GET login \xB7 GET callback \xB7 GET session \xB7 POST logout
83
+ export const Route = createFileRoute('/api/oneteam/$')({
84
+ server: { handlers: oneteam.handlers },
85
+ });
86
+ `;
87
+ }
88
+ function tanstackStartFile() {
89
+ return `import { createMiddleware, createStart } from '@tanstack/react-start';
90
+ import { oneteam } from './lib/oneteam.server';
91
+
92
+ // Deny by default: every page, server route and server function needs a
93
+ // Oneteam session unless listed in publicPaths. Only add paths that are safe
94
+ // for anyone on the internet to open. Inside .server() so the server-only
95
+ // module stays out of the browser bundle.
96
+ const oneteamMiddleware = createMiddleware().server((ctx) => oneteam.guard(ctx, { publicPaths: [] }));
97
+
98
+ export const startInstance = createStart(() => ({
99
+ requestMiddleware: [oneteamMiddleware],
100
+ }));
101
+ `;
102
+ }
103
+ function tanstackFunctionsFile() {
104
+ return `import { createServerFn } from '@tanstack/react-start';
105
+ import { oneteam } from './oneteam.server';
106
+
107
+ /**
108
+ * Session without tokens, for <OneteamProvider session={...}>. Load it in the
109
+ * root route: beforeLoad: async () => ({ session: await fetchSession() }).
110
+ */
111
+ export const fetchSession = createServerFn({ method: 'GET' }).handler(() => oneteam.getClientSession());
112
+ `;
113
+ }
69
114
 
70
115
  // src/commands.ts
71
116
  var MANIFEST_FILE = "oneteam.app.json";
@@ -109,6 +154,15 @@ function detectNextApp(root) {
109
154
  if (existsSync(join(root, "app"))) return { appDir: "app", srcPrefix: "" };
110
155
  return null;
111
156
  }
157
+ function hasDependency(root, name) {
158
+ const packagePath = join(root, "package.json");
159
+ if (!existsSync(packagePath)) return false;
160
+ const pkg = readJson(packagePath);
161
+ return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
162
+ }
163
+ function detectTanstackStartApp(root) {
164
+ return hasDependency(root, "@tanstack/react-start") && existsSync(join(root, "src", "routes"));
165
+ }
112
166
  var AGENTS_FILE = "AGENTS.md";
113
167
  var CURSOR_RULE_FILE = ".cursor/rules/oneteam-apps.mdc";
114
168
  var AGENTS_MARKER = "<!-- oneteam-apps:start -->";
@@ -138,28 +192,18 @@ ${section}
138
192
  writeIfMissing(root, CURSOR_RULE_FILE, CURSOR_RULE, out2, force);
139
193
  }
140
194
  function init(root, options, out2) {
195
+ const force = options.force ?? false;
141
196
  const next = detectNextApp(root);
142
- if (!next) {
197
+ if (next) {
198
+ initNext(root, next, out2, force);
199
+ } else if (detectTanstackStartApp(root)) {
200
+ initTanstackStart(root, out2, force);
201
+ } else {
143
202
  out2.error(
144
- 'No Next.js App Router project found (needs "next" in package.json and an app/ or src/app/ directory). Other frameworks: use @oneteam-app/apps directly, see the README.'
203
+ 'No supported project found: Next.js App Router ("next" in package.json and an app/ or src/app/ directory) or TanStack Start ("@tanstack/react-start" in package.json and a src/routes/ directory). Other frameworks: use @oneteam-app/apps directly, see the README.'
145
204
  );
146
205
  return 1;
147
206
  }
148
- const force = options.force ?? false;
149
- const libPath = `${next.srcPrefix}lib/oneteam.ts`;
150
- const libImport = "@/lib/oneteam";
151
- writeIfMissing(root, libPath, libFile(next.srcPrefix ? "../../oneteam.app.json" : "../oneteam.app.json"), out2, force);
152
- writeIfMissing(root, `${next.appDir}/api/oneteam/[...oneteam]/route.ts`, routeFile(libImport), out2, force);
153
- const existingGate = ["proxy.ts", "middleware.ts", "src/proxy.ts", "src/middleware.ts"].find(
154
- (file) => existsSync(join(root, file))
155
- );
156
- if (existingGate && !force) {
157
- out2.warn(
158
- `found ${existingGate}: not replacing it. Call oneteam.proxy() from it so every route requires a session (see the example app).`
159
- );
160
- } else {
161
- writeIfMissing(root, `${next.srcPrefix}proxy.ts`, proxyFile(libImport), out2, force);
162
- }
163
207
  writeAgentInstructions(root, out2, force);
164
208
  if (!existsSync(join(root, MANIFEST_FILE)) || force) {
165
209
  const name = options.name ?? readJson(join(root, "package.json")).name ?? basename(root);
@@ -182,11 +226,39 @@ ${key}=${value ?? ""}`).join("\n");
182
226
  out2.info("");
183
227
  out2.info("Next steps:");
184
228
  out2.info(" 1. npm install @oneteam-app/apps");
185
- out2.info(` 2. Register the app in Oneteam: Settings \u2192 Apps & Shortcuts \u2192 Apps \u2192 New app (upload ${MANIFEST_FILE})`);
186
- out2.info(" 3. Put the client id and secret in .env.local, plus ONETEAM_SESSION_SECRET");
229
+ out2.info(" 2. Register the app in Oneteam: the register_app tool of the Oneteam MCP connector (as an admin), or the dashboard");
230
+ out2.info(" 3. Put the client id and secret in .env.local and your host's secret settings, plus ONETEAM_SESSION_SECRET");
187
231
  out2.info(" 4. npx oneteam-apps doctor");
188
232
  return 0;
189
233
  }
234
+ function initNext(root, next, out2, force) {
235
+ const libPath = `${next.srcPrefix}lib/oneteam.ts`;
236
+ const libImport = "@/lib/oneteam";
237
+ writeIfMissing(root, libPath, libFile(next.srcPrefix ? "../../oneteam.app.json" : "../oneteam.app.json"), out2, force);
238
+ writeIfMissing(root, `${next.appDir}/api/oneteam/[...oneteam]/route.ts`, routeFile(libImport), out2, force);
239
+ const existingGate = ["proxy.ts", "middleware.ts", "src/proxy.ts", "src/middleware.ts"].find(
240
+ (file) => existsSync(join(root, file))
241
+ );
242
+ if (existingGate && !force) {
243
+ out2.warn(
244
+ `found ${existingGate}: not replacing it. Call oneteam.proxy() from it so every route requires a session (see the example app).`
245
+ );
246
+ } else {
247
+ writeIfMissing(root, `${next.srcPrefix}proxy.ts`, proxyFile(libImport), out2, force);
248
+ }
249
+ }
250
+ function initTanstackStart(root, out2, force) {
251
+ writeIfMissing(root, "src/lib/oneteam.server.ts", tanstackLibFile("../../oneteam.app.json"), out2, force);
252
+ writeIfMissing(root, "src/lib/oneteam.functions.ts", tanstackFunctionsFile(), out2, force);
253
+ writeIfMissing(root, "src/routes/api/oneteam/$.ts", tanstackRouteFile(), out2, force);
254
+ if (existsSync(join(root, "src", "start.ts")) && !force) {
255
+ out2.warn(
256
+ "found src/start.ts: not replacing it. Add a request middleware that calls oneteam.guard() so every request requires a session (see the TanStack Start example app)."
257
+ );
258
+ } else {
259
+ writeIfMissing(root, "src/start.ts", tanstackStartFile(), out2, force);
260
+ }
261
+ }
190
262
  function validate(root, file, out2) {
191
263
  const path = join(root, file ?? MANIFEST_FILE);
192
264
  if (!existsSync(path)) {
@@ -205,7 +277,7 @@ function validate(root, file, out2) {
205
277
  for (const issue of result.errors) out2.error(`${issue.path}: ${issue.message}`);
206
278
  return { code: 1 };
207
279
  }
208
- out2.info(`${MANIFEST_FILE} is valid (${result.manifest.permissions.length} permissions)`);
280
+ out2.info(`${MANIFEST_FILE} is valid (scopes: ${result.manifest.scopes.join(" ")})`);
209
281
  return { code: 0, manifest: result.manifest };
210
282
  }
211
283
  function installedVersion(root, name) {
@@ -247,8 +319,8 @@ async function doctor(root, options, out2) {
247
319
  if (!env[key]) fail(`${key} is not set (.env.local)`);
248
320
  }
249
321
  for (const key of Object.keys(env)) {
250
- if (/^NEXT_PUBLIC_ONETEAM_(CLIENT_SECRET|SESSION_SECRET)/.test(key)) {
251
- fail(`${key} would ship a secret to the browser. Remove the NEXT_PUBLIC_ prefix.`);
322
+ if (/^(NEXT_PUBLIC|VITE)_ONETEAM_(CLIENT_SECRET|SESSION_SECRET)/.test(key)) {
323
+ fail(`${key} would ship a secret to the browser. Remove the ${key.startsWith("VITE_") ? "VITE_" : "NEXT_PUBLIC_"} prefix.`);
252
324
  }
253
325
  }
254
326
  if (env.ONETEAM_SESSION_SECRET && env.ONETEAM_SESSION_SECRET.length < 32) {
@@ -263,13 +335,22 @@ async function doctor(root, options, out2) {
263
335
  fail(`${callback} is not in ${MANIFEST_FILE} redirect_uris; Oneteam will reject the login`);
264
336
  }
265
337
  }
266
- const gate = ["proxy.ts", "middleware.ts", "src/proxy.ts", "src/middleware.ts"].find(
267
- (file) => existsSync(join(root, file))
268
- );
269
- if (!gate) {
270
- fail("no proxy.ts: routes are not protected by default. Run `oneteam-apps init`.");
271
- } else if (!readFileSync(join(root, gate), "utf8").includes("oneteam.proxy")) {
272
- out2.warn(`${gate} does not call oneteam.proxy(); make sure it protects every route`);
338
+ if (detectTanstackStartApp(root)) {
339
+ const start = join(root, "src", "start.ts");
340
+ if (!existsSync(start)) {
341
+ fail("no src/start.ts: requests are not protected by default. Run `oneteam-apps init`.");
342
+ } else if (!readFileSync(start, "utf8").includes("oneteam.guard")) {
343
+ fail("src/start.ts does not call oneteam.guard() from a request middleware: requests are not protected by default");
344
+ }
345
+ } else {
346
+ const gate = ["proxy.ts", "middleware.ts", "src/proxy.ts", "src/middleware.ts"].find(
347
+ (file) => existsSync(join(root, file))
348
+ );
349
+ if (!gate) {
350
+ fail("no proxy.ts: routes are not protected by default. Run `oneteam-apps init`.");
351
+ } else if (!readFileSync(join(root, gate), "utf8").includes("oneteam.proxy")) {
352
+ out2.warn(`${gate} does not call oneteam.proxy(); make sure it protects every route`);
353
+ }
273
354
  }
274
355
  const nextVersion = installedVersion(root, "next");
275
356
  if (nextVersion && !isPatchedNext(nextVersion)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneteam-app/apps-cli",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.2",
4
4
  "description": "Set up and check Oneteam login in an app: init, validate, doctor.",
5
5
  "keywords": [
6
6
  "oneteam",
@@ -24,6 +24,7 @@
24
24
  "oneteam-apps": "./dist/index.js"
25
25
  },
26
26
  "files": [
27
+ "CHANGELOG.md",
27
28
  "LICENSE",
28
29
  "README.md",
29
30
  "dist"
@@ -36,7 +37,7 @@
36
37
  "provenance": false
37
38
  },
38
39
  "dependencies": {
39
- "@oneteam-app/apps": "0.1.0-alpha.0"
40
+ "@oneteam-app/apps": "0.1.0-alpha.2"
40
41
  },
41
42
  "scripts": {
42
43
  "build": "tsup",