@oneteam-app/apps-cli 0.0.0-stage → 0.1.0-alpha.1
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.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +24 -2
- package/dist/index.js +426 -0
- package/package.json +44 -3
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oneteam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @oneteam-app/apps-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Adds Oneteam login to a Next.js App Router project and checks the setup.
|
|
4
|
+
|
|
5
|
+
> **Alpha.** Expect changes before 1.0.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @oneteam-app/apps-cli init --url https://your-app.example.com
|
|
9
|
+
npx @oneteam-app/apps-cli validate
|
|
10
|
+
npx @oneteam-app/apps-cli doctor
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
| Command | Does |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `init` | Writes `lib/oneteam.ts`, the auth route handler, a deny-by-default `proxy.ts`, `oneteam.app.json`, `.env.example` entries, and the rules coding agents read (`AGENTS.md`, a Cursor rule). Never overwrites existing files without `--force`, and never replaces an existing middleware |
|
|
16
|
+
| `validate` | Checks `oneteam.app.json` against the manifest schema |
|
|
17
|
+
| `doctor` | Checks the manifest, environment variables, route protection and that the Oneteam issuer is reachable. Use `--offline` to skip the network check |
|
|
18
|
+
|
|
19
|
+
Non-interactive by design, so AI coding tools can run it.
|
|
20
|
+
|
|
21
|
+
Documentation: https://github.com/FlexAppeal/oneteam-apps
|
|
22
|
+
|
|
23
|
+
## License
|
|
24
|
+
|
|
25
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { parseArgs } from "util";
|
|
5
|
+
|
|
6
|
+
// src/commands.ts
|
|
7
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
|
|
8
|
+
import { basename, dirname, join, relative, sep } from "path";
|
|
9
|
+
import { validateManifest } from "@oneteam-app/apps/contracts";
|
|
10
|
+
|
|
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) 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## 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 (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); never ask them to paste the secret into the chat, and never\n 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## 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 (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
|
+
|
|
15
|
+
// src/templates.ts
|
|
16
|
+
var ENV_KEYS = [
|
|
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_ or VITE_" },
|
|
19
|
+
{ key: "ONETEAM_SESSION_SECRET", comment: "At least 32 random characters: openssl rand -base64 32" },
|
|
20
|
+
{ key: "ONETEAM_APP_URL", comment: "Public base URL of this app; must match a registered redirect URI", value: "http://localhost:3000" }
|
|
21
|
+
];
|
|
22
|
+
function libFile(manifestImport) {
|
|
23
|
+
return `import { createOneteam } from '@oneteam-app/apps/next';
|
|
24
|
+
import manifest from '${manifestImport}';
|
|
25
|
+
|
|
26
|
+
// Request exactly the scopes the manifest declares (and the admin approves).
|
|
27
|
+
export const oneteam = createOneteam({ scopes: manifest.scopes });
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
function routeFile(libImport) {
|
|
31
|
+
return `import { oneteam } from '${libImport}';
|
|
32
|
+
|
|
33
|
+
export const { GET, POST } = oneteam.handlers;
|
|
34
|
+
`;
|
|
35
|
+
}
|
|
36
|
+
function proxyFile(libImport) {
|
|
37
|
+
return `import { oneteam } from '${libImport}';
|
|
38
|
+
|
|
39
|
+
// Deny by default: every route needs a Oneteam session unless listed here.
|
|
40
|
+
// Only add paths that are safe for anyone on the internet to open.
|
|
41
|
+
export const proxy = oneteam.proxy({
|
|
42
|
+
publicPaths: [],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const config = {
|
|
46
|
+
// Only Next.js internals skip the proxy. Never exclude by file extension:
|
|
47
|
+
// /api/receipts/123.jpg is data, not a static asset. Public files go under
|
|
48
|
+
// /public/assets and are listed in publicPaths as '/assets/*'.
|
|
49
|
+
matcher: ['/((?!_next/static/|_next/image$|favicon\\\\.ico$).*)'],
|
|
50
|
+
};
|
|
51
|
+
`;
|
|
52
|
+
}
|
|
53
|
+
function manifest(name, productionUrl) {
|
|
54
|
+
const base = productionUrl?.replace(/\/+$/, "") ?? "https://REPLACE-WITH-PRODUCTION-DOMAIN";
|
|
55
|
+
return {
|
|
56
|
+
$schema: "https://oneteam.io/schemas/app.v1.json",
|
|
57
|
+
schema_version: 1,
|
|
58
|
+
name,
|
|
59
|
+
login_uri: `${base}/api/oneteam/login`,
|
|
60
|
+
redirect_uris: [`${base}/api/oneteam/callback`],
|
|
61
|
+
// oneteam:permissions: the user's Oneteam permissions, for can() / requirePermission().
|
|
62
|
+
scopes: ["openid", "profile", "email", "offline_access", "oneteam:permissions"],
|
|
63
|
+
development: {
|
|
64
|
+
login_uri: "http://localhost:3000/api/oneteam/login",
|
|
65
|
+
redirect_uris: ["http://localhost:3000/api/oneteam/callback"]
|
|
66
|
+
}
|
|
67
|
+
};
|
|
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
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/commands.ts
|
|
116
|
+
var MANIFEST_FILE = "oneteam.app.json";
|
|
117
|
+
function readJson(path) {
|
|
118
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
119
|
+
}
|
|
120
|
+
function writeProjectFile(root, path, contents) {
|
|
121
|
+
const absolute = join(root, path);
|
|
122
|
+
const realRoot = realpathSync(root);
|
|
123
|
+
const isInside = (target) => target === realRoot || target.startsWith(`${realRoot}${sep}`);
|
|
124
|
+
let existingParent = dirname(absolute);
|
|
125
|
+
while (!existsSync(existingParent)) existingParent = dirname(existingParent);
|
|
126
|
+
if (!isInside(realpathSync(existingParent))) {
|
|
127
|
+
throw new Error(`refusing to write ${path}: it resolves outside the project`);
|
|
128
|
+
}
|
|
129
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
130
|
+
if (!isInside(realpathSync(dirname(absolute)))) {
|
|
131
|
+
throw new Error(`refusing to write ${path}: it resolves outside the project`);
|
|
132
|
+
}
|
|
133
|
+
if (existsSync(absolute) && lstatSync(absolute).isSymbolicLink()) {
|
|
134
|
+
throw new Error(`refusing to write ${path}: it is a symbolic link`);
|
|
135
|
+
}
|
|
136
|
+
writeFileSync(absolute, contents);
|
|
137
|
+
}
|
|
138
|
+
function writeIfMissing(root, path, contents, out2, force) {
|
|
139
|
+
const absolute = join(root, path);
|
|
140
|
+
if (existsSync(absolute) && !force) {
|
|
141
|
+
out2.warn(`skipped ${path} (already exists; use --force to overwrite)`);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
writeProjectFile(root, path, contents);
|
|
145
|
+
out2.info(`created ${path}`);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
function detectNextApp(root) {
|
|
149
|
+
const packagePath = join(root, "package.json");
|
|
150
|
+
if (!existsSync(packagePath)) return null;
|
|
151
|
+
const pkg = readJson(packagePath);
|
|
152
|
+
if (!pkg.dependencies?.next && !pkg.devDependencies?.next) return null;
|
|
153
|
+
if (existsSync(join(root, "src", "app"))) return { appDir: "src/app", srcPrefix: "src/" };
|
|
154
|
+
if (existsSync(join(root, "app"))) return { appDir: "app", srcPrefix: "" };
|
|
155
|
+
return null;
|
|
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
|
+
}
|
|
166
|
+
var AGENTS_FILE = "AGENTS.md";
|
|
167
|
+
var CURSOR_RULE_FILE = ".cursor/rules/oneteam-apps.mdc";
|
|
168
|
+
var AGENTS_MARKER = "<!-- oneteam-apps:start -->";
|
|
169
|
+
var AGENTS_END = "<!-- oneteam-apps:end -->";
|
|
170
|
+
function writeAgentInstructions(root, out2, force) {
|
|
171
|
+
const section = `${AGENTS_MARKER}
|
|
172
|
+
${AGENT_INSTRUCTIONS}
|
|
173
|
+
${AGENTS_END}`;
|
|
174
|
+
const agentsPath = join(root, AGENTS_FILE);
|
|
175
|
+
const existing = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
|
|
176
|
+
const start = existing?.indexOf(AGENTS_MARKER) ?? -1;
|
|
177
|
+
const end = existing && start >= 0 ? existing.indexOf(AGENTS_END, start) : -1;
|
|
178
|
+
if (existing === null) {
|
|
179
|
+
writeProjectFile(root, AGENTS_FILE, `${section}
|
|
180
|
+
`);
|
|
181
|
+
out2.info(`created ${AGENTS_FILE}`);
|
|
182
|
+
} else if (start >= 0 && end >= 0) {
|
|
183
|
+
writeProjectFile(root, AGENTS_FILE, `${existing.slice(0, start)}${section}${existing.slice(end + AGENTS_END.length)}`);
|
|
184
|
+
out2.info(`updated the Oneteam section in ${AGENTS_FILE}`);
|
|
185
|
+
} else {
|
|
186
|
+
if (start >= 0) out2.warn(`${AGENTS_FILE} has a Oneteam start marker without an end marker; leaving it and appending a new section`);
|
|
187
|
+
writeProjectFile(root, AGENTS_FILE, `${existing}${existing.endsWith("\n") ? "" : "\n"}
|
|
188
|
+
${section}
|
|
189
|
+
`);
|
|
190
|
+
out2.info(`added a Oneteam section to ${AGENTS_FILE}`);
|
|
191
|
+
}
|
|
192
|
+
writeIfMissing(root, CURSOR_RULE_FILE, CURSOR_RULE, out2, force);
|
|
193
|
+
}
|
|
194
|
+
function init(root, options, out2) {
|
|
195
|
+
const force = options.force ?? false;
|
|
196
|
+
const next = detectNextApp(root);
|
|
197
|
+
if (next) {
|
|
198
|
+
initNext(root, next, out2, force);
|
|
199
|
+
} else if (detectTanstackStartApp(root)) {
|
|
200
|
+
initTanstackStart(root, out2, force);
|
|
201
|
+
} else {
|
|
202
|
+
out2.error(
|
|
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.'
|
|
204
|
+
);
|
|
205
|
+
return 1;
|
|
206
|
+
}
|
|
207
|
+
writeAgentInstructions(root, out2, force);
|
|
208
|
+
if (!existsSync(join(root, MANIFEST_FILE)) || force) {
|
|
209
|
+
const name = options.name ?? readJson(join(root, "package.json")).name ?? basename(root);
|
|
210
|
+
writeIfMissing(root, MANIFEST_FILE, `${JSON.stringify(manifest(name, options.url), null, 2)}
|
|
211
|
+
`, out2, force);
|
|
212
|
+
if (!options.url) out2.warn(`set your production domain in ${MANIFEST_FILE} (or re-run with --url)`);
|
|
213
|
+
} else {
|
|
214
|
+
out2.warn(`skipped ${MANIFEST_FILE} (already exists)`);
|
|
215
|
+
}
|
|
216
|
+
const envPath = join(root, ".env.example");
|
|
217
|
+
const envContents = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
218
|
+
const missing = ENV_KEYS.filter(({ key }) => !new RegExp(`^${key}=`, "m").test(envContents));
|
|
219
|
+
if (missing.length > 0) {
|
|
220
|
+
const block = missing.map(({ key, comment, value }) => `# ${comment}
|
|
221
|
+
${key}=${value ?? ""}`).join("\n");
|
|
222
|
+
writeProjectFile(root, ".env.example", `${envContents}${envContents && !envContents.endsWith("\n") ? "\n" : ""}${block}
|
|
223
|
+
`);
|
|
224
|
+
out2.info(`added ${missing.map(({ key }) => key).join(", ")} to .env.example`);
|
|
225
|
+
}
|
|
226
|
+
out2.info("");
|
|
227
|
+
out2.info("Next steps:");
|
|
228
|
+
out2.info(" 1. npm install @oneteam-app/apps");
|
|
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");
|
|
231
|
+
out2.info(" 4. npx oneteam-apps doctor");
|
|
232
|
+
return 0;
|
|
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
|
+
}
|
|
262
|
+
function validate(root, file, out2) {
|
|
263
|
+
const path = join(root, file ?? MANIFEST_FILE);
|
|
264
|
+
if (!existsSync(path)) {
|
|
265
|
+
out2.error(`${relative(root, path) || path} not found. Run \`oneteam-apps init\` first.`);
|
|
266
|
+
return { code: 1 };
|
|
267
|
+
}
|
|
268
|
+
let input;
|
|
269
|
+
try {
|
|
270
|
+
input = readJson(path);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
out2.error(`${MANIFEST_FILE} is not valid JSON: ${error.message}`);
|
|
273
|
+
return { code: 1 };
|
|
274
|
+
}
|
|
275
|
+
const result = validateManifest(input);
|
|
276
|
+
if (!result.success) {
|
|
277
|
+
for (const issue of result.errors) out2.error(`${issue.path}: ${issue.message}`);
|
|
278
|
+
return { code: 1 };
|
|
279
|
+
}
|
|
280
|
+
out2.info(`${MANIFEST_FILE} is valid (scopes: ${result.manifest.scopes.join(" ")})`);
|
|
281
|
+
return { code: 0, manifest: result.manifest };
|
|
282
|
+
}
|
|
283
|
+
function installedVersion(root, name) {
|
|
284
|
+
try {
|
|
285
|
+
return readJson(join(root, "node_modules", name, "package.json")).version ?? null;
|
|
286
|
+
} catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function isPatchedNext(version) {
|
|
291
|
+
const [major = 0, minor = 0, patch = 0] = version.split("-")[0].split(".").map(Number);
|
|
292
|
+
if (major > 16) return true;
|
|
293
|
+
if (major === 16) return minor > 3 || minor === 3 && patch >= 8;
|
|
294
|
+
if (major === 15) return minor > 5 || minor === 5 && patch >= 28;
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
function loadEnv(root) {
|
|
298
|
+
const env = {};
|
|
299
|
+
for (const file of [".env", ".env.local"]) {
|
|
300
|
+
const path = join(root, file);
|
|
301
|
+
if (!existsSync(path)) continue;
|
|
302
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
303
|
+
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
|
|
304
|
+
if (match) env[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2");
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return env;
|
|
308
|
+
}
|
|
309
|
+
async function doctor(root, options, out2) {
|
|
310
|
+
let failures = 0;
|
|
311
|
+
const fail = (message) => {
|
|
312
|
+
failures += 1;
|
|
313
|
+
out2.error(message);
|
|
314
|
+
};
|
|
315
|
+
const env = { ...loadEnv(root), ...options.env };
|
|
316
|
+
const { manifest: parsed } = validate(root, void 0, out2);
|
|
317
|
+
if (!parsed) failures += 1;
|
|
318
|
+
for (const { key } of ENV_KEYS) {
|
|
319
|
+
if (!env[key]) fail(`${key} is not set (.env.local)`);
|
|
320
|
+
}
|
|
321
|
+
for (const key of Object.keys(env)) {
|
|
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.`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (env.ONETEAM_SESSION_SECRET && env.ONETEAM_SESSION_SECRET.length < 32) {
|
|
327
|
+
fail("ONETEAM_SESSION_SECRET must be at least 32 characters");
|
|
328
|
+
}
|
|
329
|
+
if (env.ONETEAM_APP_URL && parsed) {
|
|
330
|
+
const callback = `${env.ONETEAM_APP_URL.replace(/\/+$/, "")}/api/oneteam/callback`;
|
|
331
|
+
const registered = [...parsed.redirect_uris, ...parsed.development?.redirect_uris ?? []];
|
|
332
|
+
if (registered.includes(callback)) {
|
|
333
|
+
out2.info(`redirect URI ${callback} is in ${MANIFEST_FILE}`);
|
|
334
|
+
} else {
|
|
335
|
+
fail(`${callback} is not in ${MANIFEST_FILE} redirect_uris; Oneteam will reject the login`);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
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
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const nextVersion = installedVersion(root, "next");
|
|
356
|
+
if (nextVersion && !isPatchedNext(nextVersion)) {
|
|
357
|
+
fail(
|
|
358
|
+
`next ${nextVersion} has known security bugs, including ones that bypass proxy.ts (and with it every Oneteam check). Upgrade to 15.5.28+ or 16.3.8+.`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
if (!options.offline) {
|
|
362
|
+
const issuer = (env.ONETEAM_ISSUER ?? "https://login.oneteam.io").replace(/\/+$/, "");
|
|
363
|
+
try {
|
|
364
|
+
const response = await (options.fetch ?? fetch)(`${issuer}/.well-known/openid-configuration`);
|
|
365
|
+
const metadata = await response.json();
|
|
366
|
+
if (metadata.issuer === issuer) out2.info(`reached ${issuer}`);
|
|
367
|
+
else fail(`${issuer} returned issuer ${metadata.issuer}`);
|
|
368
|
+
} catch (error) {
|
|
369
|
+
fail(`could not reach ${issuer}: ${error.message}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
out2.info(failures === 0 ? "All checks passed" : `${failures} problem(s) found`);
|
|
373
|
+
return failures === 0 ? 0 : 1;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/index.ts
|
|
377
|
+
var HELP = `oneteam-apps: Oneteam login for your app
|
|
378
|
+
|
|
379
|
+
Usage
|
|
380
|
+
oneteam-apps init [--name <app name>] [--url <production url>] [--force]
|
|
381
|
+
oneteam-apps validate [file]
|
|
382
|
+
oneteam-apps doctor [--offline]
|
|
383
|
+
|
|
384
|
+
Commands
|
|
385
|
+
init Add Oneteam login to a Next.js App Router project (non-interactive)
|
|
386
|
+
validate Check oneteam.app.json against the manifest schema
|
|
387
|
+
doctor Check manifest, environment, route protection and connectivity
|
|
388
|
+
`;
|
|
389
|
+
var out = {
|
|
390
|
+
info: (message) => console.log(message),
|
|
391
|
+
warn: (message) => console.warn(`warning: ${message}`),
|
|
392
|
+
error: (message) => console.error(`error: ${message}`)
|
|
393
|
+
};
|
|
394
|
+
async function main(argv) {
|
|
395
|
+
const [command, ...rest] = argv;
|
|
396
|
+
const root = process.cwd();
|
|
397
|
+
if (!command || command === "--help" || command === "-h") {
|
|
398
|
+
console.log(HELP);
|
|
399
|
+
return command ? 0 : 1;
|
|
400
|
+
}
|
|
401
|
+
if (command === "init") {
|
|
402
|
+
const { values } = parseArgs({
|
|
403
|
+
args: rest,
|
|
404
|
+
options: { name: { type: "string" }, url: { type: "string" }, force: { type: "boolean" } }
|
|
405
|
+
});
|
|
406
|
+
return init(root, values, out);
|
|
407
|
+
}
|
|
408
|
+
if (command === "validate") {
|
|
409
|
+
const { positionals } = parseArgs({ args: rest, allowPositionals: true });
|
|
410
|
+
return validate(root, positionals[0], out).code;
|
|
411
|
+
}
|
|
412
|
+
if (command === "doctor") {
|
|
413
|
+
const { values } = parseArgs({ args: rest, options: { offline: { type: "boolean" } } });
|
|
414
|
+
return doctor(root, { ...values, env: process.env }, out);
|
|
415
|
+
}
|
|
416
|
+
out.error(`unknown command "${command}"`);
|
|
417
|
+
console.log(HELP);
|
|
418
|
+
return 1;
|
|
419
|
+
}
|
|
420
|
+
main(process.argv.slice(2)).then(
|
|
421
|
+
(code) => process.exit(code),
|
|
422
|
+
(error) => {
|
|
423
|
+
out.error(error instanceof Error ? error.message : String(error));
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oneteam-app/apps-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Set up and check Oneteam login in an app: init, validate, doctor.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"oneteam",
|
|
7
|
+
"cli",
|
|
8
|
+
"scaffold",
|
|
9
|
+
"authentication"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"author": "Oneteam",
|
|
13
|
+
"homepage": "https://github.com/FlexAppeal/oneteam-apps/tree/main/packages/cli#readme",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/FlexAppeal/oneteam-apps.git",
|
|
17
|
+
"directory": "packages/cli"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/FlexAppeal/oneteam-apps/issues"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"oneteam-apps": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"README.md",
|
|
30
|
+
"dist"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20.9"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"provenance": false
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@oneteam-app/apps": "0.1.0-alpha.1"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"build": "tsup",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"typecheck": "tsc --noEmit"
|
|
46
|
+
}
|
|
6
47
|
}
|