@oneteam-app/apps-cli 0.0.0-stage → 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +24 -2
- package/dist/index.js +345 -0
- package/package.json +43 -3
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,345 @@
|
|
|
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), 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";
|
|
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_" },
|
|
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
|
+
scopes: ["openid", "profile", "email", "offline_access"],
|
|
62
|
+
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
|
+
|
|
70
|
+
// src/commands.ts
|
|
71
|
+
var MANIFEST_FILE = "oneteam.app.json";
|
|
72
|
+
function readJson(path) {
|
|
73
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
74
|
+
}
|
|
75
|
+
function writeProjectFile(root, path, contents) {
|
|
76
|
+
const absolute = join(root, path);
|
|
77
|
+
const realRoot = realpathSync(root);
|
|
78
|
+
const isInside = (target) => target === realRoot || target.startsWith(`${realRoot}${sep}`);
|
|
79
|
+
let existingParent = dirname(absolute);
|
|
80
|
+
while (!existsSync(existingParent)) existingParent = dirname(existingParent);
|
|
81
|
+
if (!isInside(realpathSync(existingParent))) {
|
|
82
|
+
throw new Error(`refusing to write ${path}: it resolves outside the project`);
|
|
83
|
+
}
|
|
84
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
85
|
+
if (!isInside(realpathSync(dirname(absolute)))) {
|
|
86
|
+
throw new Error(`refusing to write ${path}: it resolves outside the project`);
|
|
87
|
+
}
|
|
88
|
+
if (existsSync(absolute) && lstatSync(absolute).isSymbolicLink()) {
|
|
89
|
+
throw new Error(`refusing to write ${path}: it is a symbolic link`);
|
|
90
|
+
}
|
|
91
|
+
writeFileSync(absolute, contents);
|
|
92
|
+
}
|
|
93
|
+
function writeIfMissing(root, path, contents, out2, force) {
|
|
94
|
+
const absolute = join(root, path);
|
|
95
|
+
if (existsSync(absolute) && !force) {
|
|
96
|
+
out2.warn(`skipped ${path} (already exists; use --force to overwrite)`);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
writeProjectFile(root, path, contents);
|
|
100
|
+
out2.info(`created ${path}`);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
function detectNextApp(root) {
|
|
104
|
+
const packagePath = join(root, "package.json");
|
|
105
|
+
if (!existsSync(packagePath)) return null;
|
|
106
|
+
const pkg = readJson(packagePath);
|
|
107
|
+
if (!pkg.dependencies?.next && !pkg.devDependencies?.next) return null;
|
|
108
|
+
if (existsSync(join(root, "src", "app"))) return { appDir: "src/app", srcPrefix: "src/" };
|
|
109
|
+
if (existsSync(join(root, "app"))) return { appDir: "app", srcPrefix: "" };
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
var AGENTS_FILE = "AGENTS.md";
|
|
113
|
+
var CURSOR_RULE_FILE = ".cursor/rules/oneteam-apps.mdc";
|
|
114
|
+
var AGENTS_MARKER = "<!-- oneteam-apps:start -->";
|
|
115
|
+
var AGENTS_END = "<!-- oneteam-apps:end -->";
|
|
116
|
+
function writeAgentInstructions(root, out2, force) {
|
|
117
|
+
const section = `${AGENTS_MARKER}
|
|
118
|
+
${AGENT_INSTRUCTIONS}
|
|
119
|
+
${AGENTS_END}`;
|
|
120
|
+
const agentsPath = join(root, AGENTS_FILE);
|
|
121
|
+
const existing = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
|
|
122
|
+
const start = existing?.indexOf(AGENTS_MARKER) ?? -1;
|
|
123
|
+
const end = existing && start >= 0 ? existing.indexOf(AGENTS_END, start) : -1;
|
|
124
|
+
if (existing === null) {
|
|
125
|
+
writeProjectFile(root, AGENTS_FILE, `${section}
|
|
126
|
+
`);
|
|
127
|
+
out2.info(`created ${AGENTS_FILE}`);
|
|
128
|
+
} else if (start >= 0 && end >= 0) {
|
|
129
|
+
writeProjectFile(root, AGENTS_FILE, `${existing.slice(0, start)}${section}${existing.slice(end + AGENTS_END.length)}`);
|
|
130
|
+
out2.info(`updated the Oneteam section in ${AGENTS_FILE}`);
|
|
131
|
+
} else {
|
|
132
|
+
if (start >= 0) out2.warn(`${AGENTS_FILE} has a Oneteam start marker without an end marker; leaving it and appending a new section`);
|
|
133
|
+
writeProjectFile(root, AGENTS_FILE, `${existing}${existing.endsWith("\n") ? "" : "\n"}
|
|
134
|
+
${section}
|
|
135
|
+
`);
|
|
136
|
+
out2.info(`added a Oneteam section to ${AGENTS_FILE}`);
|
|
137
|
+
}
|
|
138
|
+
writeIfMissing(root, CURSOR_RULE_FILE, CURSOR_RULE, out2, force);
|
|
139
|
+
}
|
|
140
|
+
function init(root, options, out2) {
|
|
141
|
+
const next = detectNextApp(root);
|
|
142
|
+
if (!next) {
|
|
143
|
+
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.'
|
|
145
|
+
);
|
|
146
|
+
return 1;
|
|
147
|
+
}
|
|
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
|
+
writeAgentInstructions(root, out2, force);
|
|
164
|
+
if (!existsSync(join(root, MANIFEST_FILE)) || force) {
|
|
165
|
+
const name = options.name ?? readJson(join(root, "package.json")).name ?? basename(root);
|
|
166
|
+
writeIfMissing(root, MANIFEST_FILE, `${JSON.stringify(manifest(name, options.url), null, 2)}
|
|
167
|
+
`, out2, force);
|
|
168
|
+
if (!options.url) out2.warn(`set your production domain in ${MANIFEST_FILE} (or re-run with --url)`);
|
|
169
|
+
} else {
|
|
170
|
+
out2.warn(`skipped ${MANIFEST_FILE} (already exists)`);
|
|
171
|
+
}
|
|
172
|
+
const envPath = join(root, ".env.example");
|
|
173
|
+
const envContents = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
|
|
174
|
+
const missing = ENV_KEYS.filter(({ key }) => !new RegExp(`^${key}=`, "m").test(envContents));
|
|
175
|
+
if (missing.length > 0) {
|
|
176
|
+
const block = missing.map(({ key, comment, value }) => `# ${comment}
|
|
177
|
+
${key}=${value ?? ""}`).join("\n");
|
|
178
|
+
writeProjectFile(root, ".env.example", `${envContents}${envContents && !envContents.endsWith("\n") ? "\n" : ""}${block}
|
|
179
|
+
`);
|
|
180
|
+
out2.info(`added ${missing.map(({ key }) => key).join(", ")} to .env.example`);
|
|
181
|
+
}
|
|
182
|
+
out2.info("");
|
|
183
|
+
out2.info("Next steps:");
|
|
184
|
+
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");
|
|
187
|
+
out2.info(" 4. npx oneteam-apps doctor");
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
function validate(root, file, out2) {
|
|
191
|
+
const path = join(root, file ?? MANIFEST_FILE);
|
|
192
|
+
if (!existsSync(path)) {
|
|
193
|
+
out2.error(`${relative(root, path) || path} not found. Run \`oneteam-apps init\` first.`);
|
|
194
|
+
return { code: 1 };
|
|
195
|
+
}
|
|
196
|
+
let input;
|
|
197
|
+
try {
|
|
198
|
+
input = readJson(path);
|
|
199
|
+
} catch (error) {
|
|
200
|
+
out2.error(`${MANIFEST_FILE} is not valid JSON: ${error.message}`);
|
|
201
|
+
return { code: 1 };
|
|
202
|
+
}
|
|
203
|
+
const result = validateManifest(input);
|
|
204
|
+
if (!result.success) {
|
|
205
|
+
for (const issue of result.errors) out2.error(`${issue.path}: ${issue.message}`);
|
|
206
|
+
return { code: 1 };
|
|
207
|
+
}
|
|
208
|
+
out2.info(`${MANIFEST_FILE} is valid (${result.manifest.permissions.length} permissions)`);
|
|
209
|
+
return { code: 0, manifest: result.manifest };
|
|
210
|
+
}
|
|
211
|
+
function installedVersion(root, name) {
|
|
212
|
+
try {
|
|
213
|
+
return readJson(join(root, "node_modules", name, "package.json")).version ?? null;
|
|
214
|
+
} catch {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
function isPatchedNext(version) {
|
|
219
|
+
const [major = 0, minor = 0, patch = 0] = version.split("-")[0].split(".").map(Number);
|
|
220
|
+
if (major > 16) return true;
|
|
221
|
+
if (major === 16) return minor > 3 || minor === 3 && patch >= 8;
|
|
222
|
+
if (major === 15) return minor > 5 || minor === 5 && patch >= 28;
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
function loadEnv(root) {
|
|
226
|
+
const env = {};
|
|
227
|
+
for (const file of [".env", ".env.local"]) {
|
|
228
|
+
const path = join(root, file);
|
|
229
|
+
if (!existsSync(path)) continue;
|
|
230
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
231
|
+
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
|
|
232
|
+
if (match) env[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return env;
|
|
236
|
+
}
|
|
237
|
+
async function doctor(root, options, out2) {
|
|
238
|
+
let failures = 0;
|
|
239
|
+
const fail = (message) => {
|
|
240
|
+
failures += 1;
|
|
241
|
+
out2.error(message);
|
|
242
|
+
};
|
|
243
|
+
const env = { ...loadEnv(root), ...options.env };
|
|
244
|
+
const { manifest: parsed } = validate(root, void 0, out2);
|
|
245
|
+
if (!parsed) failures += 1;
|
|
246
|
+
for (const { key } of ENV_KEYS) {
|
|
247
|
+
if (!env[key]) fail(`${key} is not set (.env.local)`);
|
|
248
|
+
}
|
|
249
|
+
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.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (env.ONETEAM_SESSION_SECRET && env.ONETEAM_SESSION_SECRET.length < 32) {
|
|
255
|
+
fail("ONETEAM_SESSION_SECRET must be at least 32 characters");
|
|
256
|
+
}
|
|
257
|
+
if (env.ONETEAM_APP_URL && parsed) {
|
|
258
|
+
const callback = `${env.ONETEAM_APP_URL.replace(/\/+$/, "")}/api/oneteam/callback`;
|
|
259
|
+
const registered = [...parsed.redirect_uris, ...parsed.development?.redirect_uris ?? []];
|
|
260
|
+
if (registered.includes(callback)) {
|
|
261
|
+
out2.info(`redirect URI ${callback} is in ${MANIFEST_FILE}`);
|
|
262
|
+
} else {
|
|
263
|
+
fail(`${callback} is not in ${MANIFEST_FILE} redirect_uris; Oneteam will reject the login`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
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`);
|
|
273
|
+
}
|
|
274
|
+
const nextVersion = installedVersion(root, "next");
|
|
275
|
+
if (nextVersion && !isPatchedNext(nextVersion)) {
|
|
276
|
+
fail(
|
|
277
|
+
`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+.`
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
if (!options.offline) {
|
|
281
|
+
const issuer = (env.ONETEAM_ISSUER ?? "https://login.oneteam.io").replace(/\/+$/, "");
|
|
282
|
+
try {
|
|
283
|
+
const response = await (options.fetch ?? fetch)(`${issuer}/.well-known/openid-configuration`);
|
|
284
|
+
const metadata = await response.json();
|
|
285
|
+
if (metadata.issuer === issuer) out2.info(`reached ${issuer}`);
|
|
286
|
+
else fail(`${issuer} returned issuer ${metadata.issuer}`);
|
|
287
|
+
} catch (error) {
|
|
288
|
+
fail(`could not reach ${issuer}: ${error.message}`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
out2.info(failures === 0 ? "All checks passed" : `${failures} problem(s) found`);
|
|
292
|
+
return failures === 0 ? 0 : 1;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/index.ts
|
|
296
|
+
var HELP = `oneteam-apps: Oneteam login for your app
|
|
297
|
+
|
|
298
|
+
Usage
|
|
299
|
+
oneteam-apps init [--name <app name>] [--url <production url>] [--force]
|
|
300
|
+
oneteam-apps validate [file]
|
|
301
|
+
oneteam-apps doctor [--offline]
|
|
302
|
+
|
|
303
|
+
Commands
|
|
304
|
+
init Add Oneteam login to a Next.js App Router project (non-interactive)
|
|
305
|
+
validate Check oneteam.app.json against the manifest schema
|
|
306
|
+
doctor Check manifest, environment, route protection and connectivity
|
|
307
|
+
`;
|
|
308
|
+
var out = {
|
|
309
|
+
info: (message) => console.log(message),
|
|
310
|
+
warn: (message) => console.warn(`warning: ${message}`),
|
|
311
|
+
error: (message) => console.error(`error: ${message}`)
|
|
312
|
+
};
|
|
313
|
+
async function main(argv) {
|
|
314
|
+
const [command, ...rest] = argv;
|
|
315
|
+
const root = process.cwd();
|
|
316
|
+
if (!command || command === "--help" || command === "-h") {
|
|
317
|
+
console.log(HELP);
|
|
318
|
+
return command ? 0 : 1;
|
|
319
|
+
}
|
|
320
|
+
if (command === "init") {
|
|
321
|
+
const { values } = parseArgs({
|
|
322
|
+
args: rest,
|
|
323
|
+
options: { name: { type: "string" }, url: { type: "string" }, force: { type: "boolean" } }
|
|
324
|
+
});
|
|
325
|
+
return init(root, values, out);
|
|
326
|
+
}
|
|
327
|
+
if (command === "validate") {
|
|
328
|
+
const { positionals } = parseArgs({ args: rest, allowPositionals: true });
|
|
329
|
+
return validate(root, positionals[0], out).code;
|
|
330
|
+
}
|
|
331
|
+
if (command === "doctor") {
|
|
332
|
+
const { values } = parseArgs({ args: rest, options: { offline: { type: "boolean" } } });
|
|
333
|
+
return doctor(root, { ...values, env: process.env }, out);
|
|
334
|
+
}
|
|
335
|
+
out.error(`unknown command "${command}"`);
|
|
336
|
+
console.log(HELP);
|
|
337
|
+
return 1;
|
|
338
|
+
}
|
|
339
|
+
main(process.argv.slice(2)).then(
|
|
340
|
+
(code) => process.exit(code),
|
|
341
|
+
(error) => {
|
|
342
|
+
out.error(error instanceof Error ? error.message : String(error));
|
|
343
|
+
process.exit(1);
|
|
344
|
+
}
|
|
345
|
+
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oneteam-app/apps-cli",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
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
|
+
"LICENSE",
|
|
28
|
+
"README.md",
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20.9"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public",
|
|
36
|
+
"provenance": false
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@oneteam-app/apps": "0.1.0-alpha.0"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsup",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"typecheck": "tsc --noEmit"
|
|
45
|
+
}
|
|
6
46
|
}
|